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];
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];
Finally we can represent our vertex as a tuple of position and color:
#[repr(C, packed)]
struct Vertex(Pos, Color);
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])
];
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,
}
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 }
}
}
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);
}
}
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,
);
}
}
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());
}
}
}
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,
}
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 }
}
}
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());
}
}
}
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);
}
}
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);
}
}
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)
}
}
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,
)
}};
}
Now we can use our macro in the following way:
set_attribute!(vertex_array, 0, Vertex::position);
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);
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);
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:
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)