Rust and OpenGL from scratch - Vertex Attribute Format
Welcome back!
Previously,
we have spent a few lessons taking control of our gl bindings, setting up simple
resource loader from files and tidying up the error handling with the failure
crate.
It’s time we start tackling the most error-prone part of our sample code, the one that configures vertex array object attributes.
If you were not following the series from the beginning, you can jump-in by loading the code from the previous lesson, and I will try my best to give enough context to follow from there.
Current state
Currently, we set up Vertex Array Object (VAO) once:
(example, main.rs, run() function)
let mut vao: gl::types::GLuint = 0;
unsafe {
gl.GenVertexArrays(1, &mut vao);
}
// continued from here
Then, inside the unsafe block (because all the functions are unsafe, we just wrap them all inside a single unsafe block), we bind array and buffer (so that they are linked):
unsafe {
gl.BindVertexArray(vao);
gl.BindBuffer(gl::ARRAY_BUFFER, vbo);
// continued from here
}
Say that we are setting up 0
-th attribute of VAO:
gl.EnableVertexAttribArray(0); // this is "layout (location = 0)" in vertex shader
// continued from here
And and finally set up the format of this attribute. In our case, it is used to pass position to shader:
gl.VertexAttribPointer(
0, // index of the generic vertex attribute ("layout (location = 0)")
3, // the number of components per generic vertex attribute
gl::FLOAT, // data type
gl::FALSE, // normalized (int-to-float conversion)
(6 * std::mem::size_of::<f32>()) as gl::types::GLint, // stride (byte offset between consecutive attributes)
std::ptr::null() // offset of the first component
);
// continued from here
Then, we do the same for the color attribute:
gl.EnableVertexAttribArray(1); // this is "layout (location = 0)" in vertex shader
gl.VertexAttribPointer(
1, // index of the generic vertex attribute ("layout (location = 0)")
3, // the number of components per generic vertex attribute
gl::FLOAT, // data type
gl::FALSE, // normalized (int-to-float conversion)
(6 * std::mem::size_of::<f32>()) as gl::types::GLint, // stride (byte offset between consecutive attributes)
(3 * std::mem::size_of::<f32>()) as *const gl::types::GLvoid // offset of the first component
);
Note how easy it is to make a mistake here. And this is only an example of a simple triangle!
In this lesson, we will focus on a zero-cost abstraction that will help us reduce the possibility of mistakes while setting up VAOs.
What we are dealing with
VertexAttribPointer
call definition looks like this:
void glVertexAttribPointer(
GLuint index, /* index of the generic vertex attribute */
GLint size, /* the number of components per generic vertex attribute: 1, 2, 3, 4 */
GLenum type, /* data type of each component, like GL_FLOAT */
GLboolean normalized, /* fixed-point data values converted to floats */
GLsizei stride, /* byte offset between consecutive generic vertex attributes */
const GLvoid * pointer /* offset of the first component of the first generic vertex attribute */
);
VertexAttribPointer is a complex API call, that can configure at least
66 different attribute formats.
It has 3 function variants (glVertexAttribPointer
, glVertexAttribIPointer
, glVertexAttribLPointer
),
varying type
parameters per function (GL_BYTE
, GL_FLOAT
, etc.), int-to-float conversions
(so-called “normalized” values),
half-float formats or formats where 4 floating point values are packed into 4 bytes, paddings between
values configured with “stride”, various dependencies between these parameters,
different capabilities in different OpenGL versions, and driver bugs.
In fact, the last “driver bugs” point makes it abundantly clear that we won’t create a perfect abstraction here. For the awesome attempt to do that, you should check out the glium library, as well as post by its original author on why he is leaving glium.
Instead, we will focus on building VAOs in a way that gradually improves maintainability of our code while leaving the design as simple as possible.
Prerequisites
We are going to expand render_gl
submodule. We are not using
Rust 2018 syntax yet,
so we need to:
- create directory
render_gl
- move
render_gl.rs
torender_gl/mod.rs
This should compile.
With this done, we can move all the code into even deeper shader
submodule, leaving render_gl
free to define more submodules at the top.
First, create render_gl/shader.rs
, and move everything that was inside render_gl/mod.rs
there.
Then, inside the render_gl/mod.rs
we can keep the shader
submodule private (use mod shader
instead of pub mod shader
), and re-export types from render_gl::shader
as members of render_gl
:
(render_gl/mod.rs, full file)
mod shader;
pub use self::shader::{Shader, Program, Error};
This should continue compiling. If it was a bit confusing, you may always check out the final code (link should be at the end of this post).
A new type for a Vertex Attribute
Currently, our vertex data is in a continuous f32
array:
(existing code, main.rs)
// set up vertex buffer object
let vertices: Vec<f32> = vec![
// positions // colors
0.5, -0.5, 0.0, 1.0, 0.0, 0.0, // bottom right
-0.5, -0.5, 0.0, 0.0, 1.0, 0.0, // bottom left
0.0, 0.5, 0.0, 0.0, 0.0, 1.0 // top
];
This is suboptimal: rememeber, every line here contains the data that is received by the vertex shader. The data should not be limited to floats.
Instead, we can create a new type for each possible vertex attribute.
Here, we have two attributes per vertex: position and color, both are using vec3
floating
point vector. We will start by creating a type for this vector in the render_gl
module:
(render_gl/mod.rs, new line at the top)
pub mod data;
// the rest of the exsiting file
(render_gl/data.rs, new file)
#[allow(non_camel_case_types)]
#[derive(Copy, Clone, Debug)]
#[repr(C, packed)]
pub struct f32_f32_f32 {
pub d0: f32,
pub d1: f32,
pub d2: f32,
}
// continue here
We allow non_camel_case_types
, because this name does not follow Rust conventions. Of course,
you may pick another name, but I like the readability of this one.
Deriving Copy, Clone
, allows this type to be trivially copied like a primitive
int or float. The Debug
allows to print value in println
or format
call.
The repr(C, packed)
makes rust use C
-like layout, and packed
makes sure f32
components
do not contain gaps between. Otherwise Rust might align fields to 4 bytes, which is no-issue with
4-byte wide f32
, but might be an issue for other data types we are going to create in later
lessons, like i16_i16
;
therefore we will use repr(C, packed)
for everything, just to make this explicit.
There is a caveat for non-aligned value usage: modifying it through a pointer is undefined behavior. However, we can rememeber to not to do that, and Rust will at least issue a warning if we try.
This is another reason why we create a new type specifically for storing a vertex value - we should not work directly with the packed value, instead, this value should simply store the end result of some computation.
It is also a reason for not re-using Vec3
type from
some existing math library. We need full control of the layout that must be in agreement
with OpenGL spec. This also means that this struct is specific to OpenGL renderer; if we were
to write, say, Vulkan
renderer, we would take care of the structs specific to it in a
context of Vulkan
.
Let’s create a convenience constructor:
(render_gl/data.rs, continued)
impl f32_f32_f32 {
pub fn new(d0: f32, d1: f32, d2: f32) -> f32_f32_f32 {
f32_f32_f32 {
d0, d1, d2
}
}
}
// continue here
This allows us to initialize the data this way:
(example)
f32_f32_f32::new(0.5, -0.5, 0.0)
We can shrink the code a bit more if we implement conversion from tuple:
(render_gl/data.rs, continued)
impl From<(f32, f32, f32)> for f32_f32_f32 {
fn from(other: (f32, f32, f32)) -> Self {
f32_f32_f32::new(other.0, other.1, other.2)
}
}
This allows us to write more brief initialization from tuple:
let result: f32_f32_f32 = (0.5, -0.5, 0.0).into();
This magic may require some explanation: the into
method is implemented for any type
that has From
implementation for the returned value type. Implementation of Into
is
in the Rust standard library:
(rust standard library)
impl<T, U> Into<U> for T where U: From<T>
{
fn into(self) -> U {
U::from(self)
}
}
It is very useful for implementing something akin to explicit casts between types.
With that done, we can include our render_gl::data
types in main.rs
:
(main.rs, another use
statement)
use render_gl::data;
And then we can replace our vertices
initialization with our new type:
(main.rs, replace existing code)
// set up vertex buffer object
let vertices: Vec<data::f32_f32_f32> = vec![
// positions // colors
(0.5, -0.5, 0.0).into(), (1.0, 0.0, 0.0).into(), // bottom right
(-0.5, -0.5, 0.0).into(), (0.0, 1.0, 0.0).into(), // bottom left
(0.0, 0.5, 0.0).into(), (0.0, 0.0, 1.0).into() // top
];
If we run it now, we would see something undefined (or nothing) on screen.
It’s because gl.BufferData
length in bytes was defined as this:
(existing code)
vertices.len() * std::mem::size_of::<f32>()
Out element in the vector is not longer a f32
, it’s data::f32_f32_f32
.
We need to change the code to match it:
vertices.len() * std::mem::size_of::<data::f32_f32_f32>()
Let’s modify gl.BufferData
call:
(main.rs, replace gl.BufferData
call)
gl.BufferData(
gl::ARRAY_BUFFER, // target
(vertices.len() * std::mem::size_of::<data::f32_f32_f32>()) as gl::types::GLsizeiptr, // size of data in bytes
vertices.as_ptr() as *const gl::types::GLvoid, // pointer to data
gl::STATIC_DRAW, // usage
);
Let’s run it, the triangle should be back on screen!
A new type for a Vertex
To move further, we will create a new vertex type for our vertex data, which will group color and position into a single struct:
(main.rs, above fn main()
)
#[derive(Copy, Clone, Debug)]
#[repr(C, packed)]
struct Vertex {
pos: data::f32_f32_f32,
clr: data::f32_f32_f32,
}
We define this vertex inside main, because we will customize it later for whatever
shader we will be writing. Right now we named struct Vertex
, because there are no other types
of vertices, but we can imagine types like MetalShaderVertex
or ParticleSystemVertex
.
And finally, let’s modify vertices
initialization:
(main.rs, replace code)
// set up vertex buffer object
let vertices: Vec<Vertex> = vec![
Vertex { pos: (0.5, -0.5, 0.0).into(), clr: (1.0, 0.0, 0.0).into() }, // bottom right
Vertex { pos: (-0.5, -0.5, 0.0).into(), clr: (0.0, 1.0, 0.0).into() }, // bottom left
Vertex { pos: (0.0, 0.5, 0.0).into(), clr: (0.0, 0.0, 1.0).into() } // top
];
Some verbosity here is not an issue. In a real application, we would rarely load this data by hand: usually we would import it from a mesh file (exported from some 3D modeling program) or auto-generate it procedurally.
Let’s not forget to fix gl.BufferData
call (use size_of::<Vertex>
):
(main.rs, replace code)
gl.BufferData(
gl::ARRAY_BUFFER, // target
(vertices.len() * std::mem::size_of::<Vertex>()) as gl::types::GLsizeiptr, // size of data in bytes
vertices.as_ptr() as *const gl::types::GLvoid, // pointer to data
gl::STATIC_DRAW, // usage
);
The code should compile and we should be again greeted by our humble triangle.
Nicer vertex attribute pointers Setup
Currently, the code that sets up vertex attribute pointer with gl.EnableVertexAttribArray
and gl.VertexAttribPointer
would be very error-prone to maintain. Using our new
Vertex
as a starting point, we are going to gradually refactor this set up code…
(main.rs, snippet)
// replace this code
gl.EnableVertexAttribArray(0); // this is "layout (location = 0)" in vertex shader
gl.VertexAttribPointer(
0, // index of the generic vertex attribute ("layout (location = 0)")
3, // the number of components per generic vertex attribute
gl::FLOAT, // data type
gl::FALSE, // normalized (int-to-float conversion)
(6 * std::mem::size_of::<f32>()) as gl::types::GLint, // stride (byte offset between consecutive attributes)
std::ptr::null() // offset of the first component
);
gl.EnableVertexAttribArray(1); // this is "layout (location = 0)" in vertex shader
gl.VertexAttribPointer(
1, // index of the generic vertex attribute ("layout (location = 0)")
3, // the number of components per generic vertex attribute
gl::FLOAT, // data type
gl::FALSE, // normalized (int-to-float conversion)
(6 * std::mem::size_of::<f32>()) as gl::types::GLint, // stride (byte offset between consecutive attributes)
(3 * std::mem::size_of::<f32>()) as *const gl::types::GLvoid // offset of the first component
);
…into a call on the Vertex
:
(main.rs, replace previous snippet)
// replace previous code with this
Vertex::vertex_attrib_pointers(&gl);
Let’s create this function on Vertex
type (this function does not have self
parameter,
so it is similar to a static method in other languages):
(main.rs, bellow struct Vertex { ... }
)
impl Vertex {
fn vertex_attrib_pointers(gl: &gl::Gl) {
unsafe {
gl.EnableVertexAttribArray(0); // this is "layout (location = 0)" in vertex shader
gl.VertexAttribPointer(
0, // index of the generic vertex attribute ("layout (location = 0)")
3, // the number of components per generic vertex attribute
gl::FLOAT, // data type
gl::FALSE, // normalized (int-to-float conversion)
(6 * std::mem::size_of::<f32>()) as gl::types::GLint, // stride (byte offset between consecutive attributes)
std::ptr::null() // offset of the first component
);
gl.EnableVertexAttribArray(1); // this is "layout (location = 0)" in vertex shader
gl.VertexAttribPointer(
1, // index of the generic vertex attribute ("layout (location = 0)")
3, // the number of components per generic vertex attribute
gl::FLOAT, // data type
gl::FALSE, // normalized (int-to-float conversion)
(6 * std::mem::size_of::<f32>()) as gl::types::GLint, // stride (byte offset between consecutive attributes)
(3 * std::mem::size_of::<f32>()) as *const gl::types::GLvoid // offset of the first component
);
}
}
}
(The above should compile)
Here, we will continue to move two very similar parts into an implementation
on data::f32_f32_f32
type. But first, let’s refactor vertex_attrib_pointers
code a bit
so that it would be easy to see the duplicate code:
(main.rs, rewritten Vertex::vertex_attrib_pointers
implementation)
impl Vertex {
fn vertex_attrib_pointers(gl: &gl::Gl) {
let stride = std::mem::size_of::<Self>(); // byte offset between consecutive attributes
let location = 0; // layout (location = 0)
let offset = 0; // offset of the first component
unsafe {
gl.EnableVertexAttribArray(location);
gl.VertexAttribPointer(
location,
3, // the number of components per generic vertex attribute
gl::FLOAT, // data type
gl::FALSE, // normalized (int-to-float conversion)
stride as gl::types::GLint,
offset as *const gl::types::GLvoid
);
}
let location = 1; // layout (location = 1)
let offset = offset + std::mem::size_of::<data::f32_f32_f32>(); // offset of the first component
unsafe {
gl.EnableVertexAttribArray(location);
gl.VertexAttribPointer(
location,
3, // the number of components per generic vertex attribute
gl::FLOAT, // data type
gl::FALSE, // normalized (int-to-float conversion)
stride as gl::types::GLint,
offset as *const gl::types::GLvoid
);
}
}
}
(The above should compile)
The magic numbers got replaced by the actual sizes of Vertex
and its components:
6 * std::mem::size_of::<f32>()
got replaced bystd::mem::size_of::<Self>()
(whereSelf
refers to “this”Vertex
type);3 * std::mem::size_of::<f32>()
got replaced bystd::mem::size_of::<data::f32_f32_f32>()
.
Most importantly, the code in unsafe
blocks is exactly the same, and can be now
moved to a function on data::f32_f32_f32
type:
(render_gl/data.rs, inside impl f32_f32_f32
block)
pub unsafe fn vertex_attrib_pointer(gl: &gl::Gl, stride: usize, location: usize, offset: usize) {
gl.EnableVertexAttribArray(location as gl::types::GLuint);
gl.VertexAttribPointer(
location as gl::types::GLuint,
3, // the number of components per generic vertex attribute
gl::FLOAT, // data type
gl::FALSE, // normalized (int-to-float conversion)
stride as gl::types::GLint,
offset as *const gl::types::GLvoid
);
}
(don’t forget small use gl;
at the top of render_gl/data.rs
)
And the Vertex::vertex_attrib_pointers
can be futher simplified to:
(main.rs, replaced vertex_attrib_pointer
code)
impl Vertex {
fn vertex_attrib_pointers(gl: &gl::Gl) {
let stride = std::mem::size_of::<Self>(); // byte offset between consecutive attributes
let location = 0; // layout (location = 0)
let offset = 0; // offset of the first component
unsafe {
data::f32_f32_f32::vertex_attrib_pointer(gl, stride, location, offset);
}
let location = 1; // layout (location = 1)
let offset = offset + std::mem::size_of::<data::f32_f32_f32>(); // offset of the first component
unsafe {
data::f32_f32_f32::vertex_attrib_pointer(gl, stride, location, offset);
}
}
}
(The above should compile)
I’ve chosen to make f32_f32_f32::vertex_attrib_pointer
function unsafe while
leaving parent Vertex::vertex_attrib_pointers
“safe”. My reasoning is twofold. First,
it may be easier to screw something up while using f32_f32_f32::vertex_attrib_pointer
directly
(say, argument order) than Vertex::vertex_attrib_pointers
. Second, data::f32_f32_f32
is a reusable type inside a lower level library, where we want to warn “future us” about
the dangers of it, while Vertex
is a type that is specific to whatever experimental
shader we are building, and we may favor more convenience while experimenting. We may get back to this
topic in future if it starts causing issues.
This was a small change, but it added a lot of convenience. If you squint a little,
you may even start to wonder if this impl Vertex
could be auto-generated.
Next time,
we will learn about procedural macros, create our own #[derive(VertexAttributePointers)]
macro, and auto-generate vertex_attrib_pointers
code.