DEV Community 👩‍💻👨‍💻

Yury Samkevich
Yury Samkevich

Posted on

Learn OpenGL with Rust: first triangle

Welcome to the third part of Learn OpenGL with Rust tutorial. Last time we've learned how graphics pipeline of modern OpenGL works and how we can use shaders to configure it.

In this article we are going to explore what vertex buffer and vertex array objects are and how we can use them and shaders to render our first triangle. All the source code for the article you can find on github by the following link.

Vertex data

First we have to give OpenGL some input vertex data, before start drawing something on the screen. Usually this data comes in the form of vertex attributes. One of those attributes is a world position of vertex, which determines where the object or shape eventually end up on the screen. Since we are going to draw a simple triangle we will use the following representation of position:

type Pos = [f32; 2];
Enter fullscreen mode Exit fullscreen mode

We use f32 type for each of two position's component: x and y. Once vertex potion will be processed in the vertex shader, it should be in normalized device coordinates and vary between -1.0 and 1.0.

Another attribute is a vertex color. We will represent color as an array of 3 values: the red, green and blue component, commonly abbreviated to RGB. When defining a color we set the strength of each component to a value between 0.0 and 1.0.

type Color = [f32; 3];
Enter fullscreen mode Exit fullscreen mode

Finally we can represent our vertex as a tuple of position and color:

#[repr(C, packed)]
struct Vertex(Pos, Color);
Enter fullscreen mode Exit fullscreen mode

The triangle we are going to draw will consist of 3 vertices positioned at (-0.5, -0.5), (0.5, -0.5) and (0.0, 0.5) in clockwise order with red, green and blue colors accordingly:

#[rustfmt::skip]
const VERTICES: [Vertex; 3] = [
    Vertex([-0.5, -0.5], [1.0, 0.0, 0.0]),
    Vertex([0.5,  -0.5], [0.0, 1.0, 0.0]),
    Vertex([0.0,   0.5], [0.0, 0.0, 1.0])
];
Enter fullscreen mode Exit fullscreen mode

Vertex buffer object

After defining the vertex data we want to send it as an input to the first process of the graphics pipeline: the vertex shader. This could be done by creating memory on the GPU where we store the vertex data and configuring how OpenGL should interpret that memory.

In order to do that we will use vertex buffer objects (VBO) that can store a large number of vertices in the GPU's memory. Sending data to the graphics card from the CPU is relatively slow. Using VBO we can send large batches of data all at once to the graphics card and keep it there.

First we will define struct for buffer object. We will store unique id corresponding to the buffer and target for buffer type. OpenGL has many types of buffer objects and the buffer type of a vertex buffer object is gl::ARRAY_BUFFER.

pub struct Buffer {
    pub id: GLuint,
    target: GLuint,
}
Enter fullscreen mode Exit fullscreen mode

To generate a new buffer id we will use gl::GenBuffers function. The new method for buffer looks like this:

impl Buffer {
    pub unsafe fn new(target: GLuint) -> Self {
        let mut id: GLuint = 0;
        gl::GenBuffers(1, &mut id);
        Self { id, target }
    }
}
Enter fullscreen mode Exit fullscreen mode

Before uploading the actual data into buffer we first have to make it's active by calling gl::BindBuffer:

impl Buffer {
    pub unsafe fn bind(&self) {
        gl::BindBuffer(self.target, self.id);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now we have a buffer object and can make a call to the gl::BufferData function that copies the previously defined vertex data into the buffer's memory:

impl Buffer {
    pub unsafe fn set_data<D>(&self, data: &[D], usage: GLuint) {
        self.bind();
        let (_, data_bytes, _) = data.align_to::<u8>();
        gl::BufferData(
            self.target,
            data_bytes.len() as GLsizeiptr,
            data_bytes.as_ptr() as *const _,
            usage,
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

We declare function set_data which takes slice of vertices data of generic type D. The second parameter specifies how we want the graphics card to manage the given data. It could be one of 3:

  • gl::STREAM_DRAW: the vertex data is set once and drawn once
  • gl::STATIC_DRAW: the vertex data is set once and drawn many times (as in our case with triangle)
  • gl::DYNAMIC_DRAW: the vertex data is changed a lot and drawn many times

Before upload data with gl::BufferData we transform our vertex data, since gl::BufferData receives data as a byte array.

To delete a buffer once we don't need it anymore we implement Drop trait and call gl::DeleteBuffers function with buffer id as an argument:

impl Drop for Buffer {
    fn drop(&mut self) {
        unsafe {
            gl::DeleteBuffers(1, [self.id].as_ptr());
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Vertex array object

Last time we talked that vertex shaders allow us to specify any input we want in the form of vertex attributes. In order to do that we have to manually specify what part of our input data goes to which vertex attribute in the vertex shader. But before doing that we have to create and bind vertex array object (VAO). After that any vertex attribute configuration will be stored inside a VAO. This makes switching between different vertex data and attribute configurations as easy as binding a different VAO.

The struct for VAO looks similar to VBO:

pub struct VertexArray {
    pub id: GLuint,
}
Enter fullscreen mode Exit fullscreen mode

To generate a new VAO id we use gl::GenVertexArrays:

impl VertexArray {
    pub unsafe fn new() -> Self {
        let mut id: GLuint = 0;
        gl::GenVertexArrays(1, &mut id);
        Self { id }
    }
}
Enter fullscreen mode Exit fullscreen mode

Like for VBO we implement Drop for VertexArray trait to clean up unused resources:

impl Drop for VertexArray {
    fn drop(&mut self) {
        unsafe {
            gl::DeleteVertexArrays(1, [self.id].as_ptr());
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

To use a VAO all you have to do is to bind it using gl::BindVertexArray:

impl VertexArray {
    pub unsafe fn bind(&self) {
        gl::BindVertexArray(self.id);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now we can tell OpenGL how it should interpret the vertex data for each attribute using gl::VertexAttribPointer:

impl VertexArray {
    pub unsafe fn set_attribute<V: Sized>(
        &self,
        attrib_pos: GLuint,
        components: GLint,
        offset: GLint,
    ) {
        self.bind();
        gl::VertexAttribPointer(
            attrib_pos,
            components,
            gl::FLOAT,
            gl::FALSE,
            std::mem::size_of::<V>() as GLint,
            offset as *const _,
        );
        gl::EnableVertexAttribArray(attrib_pos);
    }
}
Enter fullscreen mode Exit fullscreen mode

We define set_attribute with generic type V which represents vertex layout. The first parameter specifies which vertex attribute we want to configure. The next argument specifies the number of components in the vertex attribute. The last parameter is the offset of where the position data begins in the buffer. For simplicity we assume that type of data in a vertex attribute is always f32.

In order to get a vertex attribute location in vertex shader we will modify ShaderProgram from the last article adding the following method:

impl ShaderProgram {
    pub unsafe fn get_attrib_location(&self, attrib: &str) -> Result<GLuint, NulError> {
        let attrib = CString::new(attrib)?;
        Ok(gl::GetAttribLocation(self.id, attrib.as_ptr()) as GLuint)
    }
}
Enter fullscreen mode Exit fullscreen mode

Configuring all this information manually for each of vertex attributes might be tedious and error prone. It would be nice to automatically calculate number of components and a field offset in data type that represents vertex layout knowing a name of the field. For that we are going to use a bit of Rust's macro magic:

#[macro_export]
macro_rules! set_attribute {
    ($vbo:ident, $pos:tt, $t:ident :: $field:tt) => {{
        let dummy = core::mem::MaybeUninit::<$t>::uninit();
        let dummy_ptr = dummy.as_ptr();
        let member_ptr = core::ptr::addr_of!((*dummy_ptr).$field);
        const fn size_of_raw<T>(_: *const T) -> usize {
            core::mem::size_of::<T>()
        }
        let member_offset = member_ptr as i32 - dummy_ptr as i32;
        $vbo.set_attribute::<$t>(
            $pos,
            (size_of_raw(member_ptr) / core::mem::size_of::<f32>()) as i32,
            member_offset,
        )
    }};
}
Enter fullscreen mode Exit fullscreen mode

Now we can use our macro in the following way:

set_attribute!(vertex_array, 0, Vertex::position);
Enter fullscreen mode Exit fullscreen mode

Inside the macro we calculate offset to the field position in type Vertex, size of the field and pass this information to set_attribute function of vertex array.

Rendering a triangle

Finally, we can put everything together to render our first triangle. First we compile our shaders and link them into a program. Then we create a vertex buffer object and upload vertex data for our triangle into it. After that we create vertex array object and configure vertex attributes:

let vertex_shader = Shader::new(VERTEX_SHADER_SOURCE, gl::VERTEX_SHADER)?;
let fragment_shader = Shader::new(FRAGMENT_SHADER_SOURCE, gl::FRAGMENT_SHADER)?;
let program = ShaderProgram::new(&[vertex_shader, fragment_shader])?;
let vertex_buffer = Buffer::new(gl::ARRAY_BUFFER);
vertex_buffer.set_data(&VERTICES, gl::STATIC_DRAW);
let vertex_array = VertexArray::new();
let pos_attrib = program.get_attrib_location("position")?;
set_attribute!(vertex_array, pos_attrib, Vertex::0);
let color_attrib = program.get_attrib_location("color")?;
set_attribute!(vertex_array, color_attrib, Vertex::1);
Enter fullscreen mode Exit fullscreen mode

Now when the vertex data is loaded, shader program is created and the data to the attributes is linked, all that's left is to simply use our program and VAO and call gl::DrawArrays in the main loop:

gl::ClearColor(0.3, 0.3, 0.3, 1.0);
gl::Clear(gl::COLOR_BUFFER_BIT);
self.program.apply();
self.vertex_array.bind();
gl::DrawArrays(gl::TRIANGLES, 0, 3);
Enter fullscreen mode Exit fullscreen mode

The first parameter of gl::DrawArrays specifies the kind of primitive we want to draw, the second parameter specifies the starting index of the vertex array and the last parameter specifies the number of vertices we want to draw.

Now if we run our program with cargo run, we should see the following result:

Image description

Congratulations! You just drew your first triangle using OpenGL.

Summary

Today we've learned what vertex buffer and vertex array objects are and how to use them to render simple primitives.

Next time we are going to learn what the texture is and how to draw pictures in OpenGL. Stay tuned!

If you find the article interesting consider hit the like button and subscribe for updates.

Top comments (0)

DEV

Thank you.

 
Thanks for visiting DEV, we’ve worked really hard to cultivate this great community and would love to have you join us. If you’d like to create an account, you can sign up here.