Hello Triangle
Last time, we created a program that showed us a blank window. Not very interesting.
This time, we will render a triangle over the background…
Mildly more interesting.
In any case, this program will build up on what we learned last time, with the code essentially being added on top of the Hello Window file. This means that we will reuse most of the code with setting up the OpenGL context, and the OpenGL function bindings. We won’t touch the event loop code either, rather we will learn how to write some very basic shaders to render a triangle on the screen. If you are interested in looking at the full code, it is as always found in my repository.
Vertex Shader
To draw a shape on screen, we need to give OpenGL the set of vertices that correspond to the shape. Not only that, we must also describe how we want to place these vertices. Say we have the three coordinates that represent a triangle’s vertices. We need to tell OpenGL what we want it to do with the vertices. For example, if we wanted to stretch the triangle vertically, then we would multiply the vertices’ \(y\)-coordinate by some factor. We give such a description using a vertex shader.
The vertex shader is the programmable stage in the rendering pipeline that handles the processing of individual vertices. To these shaders we pass the vertex data (e.g. the coordinates of our triangle) which is contained within a vertex array object. A vertex shader then receives a single vertex from the vertex stream and generates a single vertex to the output vertex stream.
In our program, the first thing we do is setup our vertex shader, whose code is given here:
// The GLSL shader code
const VERTEX_SHADER_SOURCE: &str = r#"
#version 330 core
layout (location = 0) in vec3 aPos;
void main() {
gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}
"#;
Here we have a constant string in rust that contains a piece of GLSL code. GLSL is the language used to write OpenGL’s shaders. It describes a shader that takes a 3-valued vector position, and outputs the same coordinate as a 4-valued vector. The line layout (location = 0) in vec3 aPos;
specifies the input data aPos
is a 3D vector, with index 0. Each user-defined input variable is assigned one or more vertex attribute indices, to which we then map our vertex data to. The main()
function of shader then specifies the output position, where gl_Position
is the position of the current output vertex. Essentially, for our array of vertices, we pass each vertex as an input to this shader, which calls the main function, and outputs the vec4
corresponding to the input.
With the vertex shader program specified, the next step is to create the shader and compile it in OpenGL with our glutin bindings. From this point on, we will be using OpenGL function bindings which are all unsafe
in rust. So, the following code is wrapped inside an unsafe
block, as seen in the repository.
// Create the vertex shader
let vertex_shader = gl::CreateShader(gl::VERTEX_SHADER);
// Convert the Rust string to a C string
let vtx_src_c_string =
std::ffi::CString::new(VERTEX_SHADER_SOURCE.as_bytes()).map_err(|e| e.to_string())?;
// Attach the shader source code to the shader object
gl::ShaderSource(
vertex_shader,
1,
&vtx_src_c_string.as_ptr(),
std::ptr::null(),
);
gl::CompileShader(vertex_shader);
First, we create a variable where we hold the OpenGL vertex shader using the function gl::CreateShader(gl::VERTEX_SHADER);
which creates an empty shader object and returns a value by which it can be referenced. Then, we convert our rust string containing the vertex shader code, into a C string that is understandable by OpenGL. Specifically, the type CString
is a borrowed reference of a C string. Next, we call gl::ShaderSource(...)
which attaches the source code the shader object just created. The third argument, where we pass the shader source code, specifies an array of pointers to strings. So we further convert the CString
from a reference to a pointer, and then pass a reference to this which is coerced to be a pointer to the newly created pointer. Then, since arrays in C are represented as pointers, our pointer to a pointer represents a single element array. Back to the ShaderSource()
function, the arguments it takes are the shader object’s handle, the number of elements in the string array of source code, the array of strings of source code, and the final argument can be set to null when the strings are null terminated which std::ffi::CString::new()
does for us. Finally, we call gl::CompileShader
which compiles the source code strings that have been stored in the shader object.
Now, here we’re compiling code in GLSL from the strings we’ve given. However, this code can contain bugs which may result in compilation errors. Hence, we want a way of at least detecting such errors in our GLSL code. The following code does just that:
// Check for shader compile errors
let mut success = gl::FALSE as gl::types::GLint;
let mut info_log = Vec::with_capacity(512);
gl::GetShaderiv(vertex_shader, gl::COMPILE_STATUS, &mut success);
if success != gl::TRUE as gl::types::GLint {
gl::GetShaderInfoLog(
vertex_shader,
512,
std::ptr::null_mut(),
info_log.as_mut_ptr() as *mut gl::types::GLchar,
);
println!(
"ERROR::SHADER::VERTEX::COMPILATION_FAILED\n{}",
std::str::from_utf8(&info_log).unwrap()
);
}
I won’t get too into the details of what this code does, but essentially GetShaderiv()
returns in the value passed as its third argument the status of a property of a shader object. Here, we query in particular the COMPILE_STATUS
of the vertex shader. Then, if this is not true, then we retrieve the information log of the vertex shader using GetShaderInforLog()
and finally print it out to the user.
Fragment Shader
At this point we’ve setup our vertex shader. Next, we setup the fragment shader. The fragment shader processes a fragment generated from the rasterized set of vertices into a set of color values. In the rendering pipeline, after the vertex shader processes our vertex data in the form of a triangle, OpenGL takes the output of the vertex shader and converts it into a fragments. This process is done automatically by OpenGl, and is called rasterization. The triangle we’re rendering is only three vertices, from which only one fragment can be made. This fragment is passed as the input of the fragment shader, which then gives a color value to “paint” the fragment in. Fragment shaders are also written in GLSL, and the code for ours is seen here:
const FRAGMENT_SHADER_SOURCE: &str = r#"
#version 330 core
out vec4 FragColor;
void main() {
FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);
}
"#;
This shader is also very simple, first specifying the output value FragColor
as a 4-element vector. Then, for each fragment we call the main()
function, which simply sets the output color to vec4(1.0f, 0.5f, 0.2f, 1.0f);
.
The Rust code to set up the fragment shader object is very similar to that of the vertex shader and is given below.
// Create the fragment shader
let fragment_shader = gl::CreateShader(gl::FRAGMENT_SHADER);
// Convert the Rust string to a C string
let frag_src_c_string =
std::ffi::CString::new(FRAGMENT_SHADER_SOURCE.as_bytes()).map_err(|e| e.to_string())?;
// Attach the shader source code to the shader object
gl::ShaderSource(
fragment_shader,
1,
&frag_src_c_string.as_ptr(),
std::ptr::null(),
);
gl::CompileShader(fragment_shader);
// Check for shader compile errors
gl::GetShaderiv(fragment_shader, gl::COMPILE_STATUS, &mut success);
if success != gl::TRUE as gl::types::GLint {
gl::GetShaderInfoLog(
fragment_shader,
512,
std::ptr::null_mut(),
info_log.as_mut_ptr() as *mut gl::types::GLchar,
);
println!(
"ERROR::SHADER::FRAGMENT::COMPILATION_FAILED\n{}",
std::str::from_utf8(&info_log).unwrap()
);
}
Since this code is essentially the same as for the vertex shader, I won’t go over it.
Shader Program
With our shader objects, the next step is to create the shader program to which we attach the shaders. The code is given here and is also quite self explanatory:
let shader_program = gl::CreateProgram();
// Attach the shaders to the program
gl::AttachShader(shader_program, vertex_shader);
gl::AttachShader(shader_program, fragment_shader);
gl::LinkProgram(shader_program);
// Check for program link errors
gl::GetProgramiv(shader_program, gl::LINK_STATUS, &mut success);
if success != gl::TRUE as gl::types::GLint {
gl::GetProgramInfoLog(
shader_program,
512,
std::ptr::null_mut(),
info_log.as_mut_ptr() as *mut gl::types::GLchar,
);
println!(
"ERROR::SHADER::PROGRAM::COMPILATION_FAILED\n{}",
std::str::from_utf8(&info_log).unwrap()
);
}
// Delete Shaders
gl::DeleteShader(vertex_shader);
gl::DeleteShader(fragment_shader);
As the code here is not very mysterious I will only mention that we can delete the shader objects at the end here, since we won’t need them anymore after linking them to the shader program.
At this point we’ve given the GPU the shader program instructing it how it should process the vertex data within the shaders we’ve designed. We still need to actually pass the vertex data to the GPU, which we haven’t yet done.
// The normalized coordinates for the triangle
let vertices: [f32; 9] = [
-0.5, -0.5, 0.0, // left
0.5, -0.5, 0.0, // right
0.0, 0.5, 0.0, // top
];
// Create a vertex buffer and vertex array object
let (mut vbo, mut vao) = (0, 0);
gl::GenVertexArrays(1, &mut vao);
gl::GenBuffers(1, &mut vbo);
// Bind the VAO first
gl::BindVertexArray(vao);
// Bind the buffer object to an array buffer
gl::BindBuffer(gl::ARRAY_BUFFER, vbo);
// Pass the triangle's vertices to the buffer
gl::BufferData(
gl::ARRAY_BUFFER,
(vertices.len() * std::mem::size_of::<gl::types::GLfloat>()) as gl::types::GLsizeiptr,
&vertices[0] as *const f32 as *const std::os::raw::c_void,
gl::STATIC_DRAW,
);
We first create an array containing the 3 vertices of our triangle. The coordinates here are given from -1 to 1, where -1 represents the left-most or bottom-most edge of the window, and 1 represents the right-most or top-most edge of the window. We can ignore the \(z\)-coordinate for now, leaving it as 0.0
, since we’re only drawing a 2D scene.
After defining the vertices, we generate a vertex buffer object, and a vertex array object. Beginning with the latter of the pair, a vertex array object stores all of the state needed to supply vertex data. It also stores the format of the vertex data, as well as the buffer objects containing the actual vertex data. A vertex buffer object is then a buffer object that serves as a source for vertex array data. Buffer Objects store an array of unformatted memory allocated by the OpenGL context (AKA the GPU). So the vertex buffer object will hold the triangle’s data in GPU memory such that we can send large batches of data all at once to the graphics card. In this manner we avoid send vertex data one by one to the GPU since this is very slow.
gl::GenVertexArrays()
and gl::GenBuffers()
returns a given number (specified as the first argument) of array objects and buffer objects respectively. These are returned as handles written to the value passed as the second argument. Normally, this would be an array of integers in C (AKA a pointer to an integer) but this is coerced to a mutable reference in Rust. After creating these objects, we then bind the vertex array object and the buffer object using gl::BindVertexArray(vao)
and gl::BindBuffer(gl::ARRAY_BUFFER, vbo)
respectively. This means that from this point on, any buffer calls we make on the gl::ARRAY_BUFFER
target will be used to configure the currently bound buffer vbo
. Likewise for the vertex array object. Lastly, at the end here we call gl::BufferData
to pass the triangle’s vertices to the buffer we just bound. In the call to the function we must pass the target, the size in bytes of the data we want to store in the buffer, a pointer to the data that will b copied into the buffer, and the usage of the data. Note that I’ve had to once again convert from rust types to the C types that OpenGL uses. The fourth parameter that I called the data usage specifies how we want the graphics card to manage the given data. Here, we’ve set it to gl::STATIC_DRAW
which means that the data is set only once and used many times. Depending on the access patterns to this data we may want to specify a different usage pattern, which will then allow OpenGL to optimize where to store this data.
After that bit of code we’ve sent the vector data to the GPU but there’s one thing left to do. We need to specify how OpenGL should interpret the vertex data we’ve just given it. We do this in the following function calls:
// Tells OpengGL how it should interpret vertex data
gl::VertexAttribPointer(
0,
3,
gl::FLOAT,
gl::FALSE,
3 * std::mem::size_of::<gl::types::GLfloat>() as gl::types::GLsizei,
std::ptr::null(),
);
gl::EnableVertexAttribArray(0);
// Unbind vbo since it's been registered in the call to VertexAttribPointer()
gl::BindBuffer(gl::ARRAY_BUFFER, 0);
The first function we call here is gl::VertexAttribPointer()
which specifies the location and data format of the array of generic vertex attributes at a given index to use when rendering. This index is the same one we specified at the beginning in our vertex shader code and is given as the first argument to the function. The other arguments are the number of components per vertex (3 for each \(x, y, z\) pair), the type of each component, whether the values should be normalized (already done as the coordinates are between -1 and 1), the byte offset between each vertex, and a pointer specifying the offset of the first component of the first vertex in the data store of the currently bound buffer. The next function call then enables the vertex attribute array given which is 0
as we linked it to the vertex shader code as well. This uses the currently bound vertex array object whose data will be passed to the vertex shader. Finally, we unbind the buffer object since it’s been registered in the call to gl::VertexAttribPointer
.
With all of that done, we’re ready to draw the triangle. All we need to do now is to update the event loop to draw the triangle whenever we redraw the screen. The code is given here
match event {
/* ... */
Event::RedrawRequested(_) => unsafe {
gl::ClearColor(0.2, 0.3, 0.3, 1.0);
gl::Clear(gl::COLOR_BUFFER_BIT);
// Draw the triangle
gl::UseProgram(shader_program);
// gl::BindVertexArray(vao); // Not necessary for this simple program
gl::DrawArrays(gl::TRIANGLES, 0, 3);
// gl::BindVertexArray(0); // Not necessary for this simple program
current_context.swap_buffers().unwrap();
},
_ => (),
}
In order to draw the triangle we add the four middle lines. The first function call to gl::UseProgram()
installs the program object as part of the current rendering state. This will contain an executable that will run on the vertex processor, running the shader programs we provided. We may then need to bind our vertex array objects in the case that we have multiple arrays of vertices that we want to run on the shaders. However, in this simple program we can leave the vertex array bound from setup, and not have to worry about unbinding it. Finally, we call gl::DrawArrays()
. This is where the magic happens and the triangle gets drawn. This function takes as first argument the primitive type we would like to draw. Then it takes the starting index of the vertex array we want to draw, namely the bound vertex array object. The final argument is the number of indices to be rendered, which is 3, one for each vertex.
With this, we have successfully drawn a triangle. It took a lot more work than anticipated. We finally start seeing the amount of setup and complex data structures that need to be used in order to do graphics programming. Moreover, since OpenGL was designed to be implemented in C, a lot of the data type conversions end up being very baroque. So we start seeing some of the difficulties in using Rust for OpenGL, where the bindings to the OpenGL functions require a fair bit of work to use in Rust. But all great things must start from humble beginnings, soon enough we’ll be drawing triple A quality graphics, no?
In any case, I hope you enjoyed reading this lengthy page, and if you’re interested in the code you can find it here. If you want to continue going on my journey into OpenGL, then please check out the next post coming soon.