How 3D Rendering Works
Triangles: The Building Blocks of Meshes
At its core, a 3D renderer takes geometric data and turns it into pixels on the screen.
In graphics programming, 3D meshes are primarily built from triangles. Three non-collinear points always define a plane, so a triangle is always flat in 3D space. This makes triangles simple for the GPU to process while still being flexible enough to approximate complex surfaces by combining many of them.
Each triangle is defined by three vertices. A vertex is a point in 3D space that can carry additional information such as its color, texture coordinates, and normal vector.
For example, one vertex in my engine looks like this:
struct Vertex {
vec3 position; // 3 floats (x, y, z)
vec2 texture; // 2 floats (u, v)
vec3 normal; // 3 floats (x, y, z)
};
The order in which the vertices are specified also matters. OpenGL uses the winding order to determine which side of a triangle is its front face. Getting this wrong can cause triangles to face the wrong direction or disappear when back-face culling is enabled.
Getting Vertex Data to the GPU
Once a mesh has been generated, its vertex data needs to reach the GPU.
Sending the data from the CPU every frame would be inefficient, especially when the same mesh is rendered repeatedly. Instead, OpenGL provides buffer objects that allow vertex data to be stored in GPU memory and reused across frames.
Three objects are particularly important when drawing a mesh:
- VBO — Vertex Buffer Object: stores the actual vertex data.
- EBO — Element Buffer Object: stores indices that specify which vertices make up each triangle.
- VAO — Vertex Array Object: stores how OpenGL should interpret the vertex data and which buffers are associated with that layout.
The easiest way I found to remember them is:
- VBO = the data
- EBO = the indices
- VAO = how to interpret the data
Telling OpenGL What a Vertex Looks Like
Suppose the VBO contains vertices laid out as:
[position][texture][normal][position][texture][normal]...
OpenGL needs to know where each attribute starts and how many bytes it needs to move to find the next vertex.
I set this up with:
vao.bind();
vao.link_attrib(vbo, 0, 3, float, false,
sizeof(Vertex), (void*)0);
vao.link_attrib(vbo, 1, 2, float, false,
sizeof(Vertex), (void*)(3 * sizeof(float)));
vao.link_attrib(vbo, 2, 3, float, false,
sizeof(Vertex), (void*)(5 * sizeof(float)));
The important parameters are:
- layout: the attribute location used by the vertex shader.
- count: the number of components in the attribute (
3forvec3,2forvec2). - type: the data type of each component.
- normalized: whether integer data should be normalized when read.
- stride: the size of one complete vertex, telling OpenGL where the next vertex begins.
- offset: the position of this attribute within the vertex.
For example, the texture coordinates start after the three position floats, so their offset is:
3 * sizeof(float)
Getting these offsets or strides wrong can make the entire mesh disappear. I spent hours debugging this before realizing that OpenGL was reading my vertex data from the wrong locations.
Reusing Vertices with an EBO
There is another problem with storing triangles directly in a VBO: the same vertex can belong to multiple triangles.
For example, a square is made from two triangles. If the vertices are stored directly for each triangle, the shared vertices have to be stored twice:
A B C
B D C
Instead, the VBO can store the four unique vertices:
A B C D
and the EBO can store the indices:
0 1 2
1 3 2
The indices tell OpenGL which vertices to use for each triangle.
This avoids duplicating vertex data and becomes increasingly useful as meshes get larger.
Drawing the Mesh
Once the vertex data, layout, and indices are on the GPU, drawing the mesh becomes relatively simple:
vertex_shader.activate();
vao.bind();
ebo.bind();
glDrawElements(
GL_TRIANGLES,
indices.size(),
GL_UNSIGNED_INT,
0
);
glDrawElements tells OpenGL to draw triangles using the indices stored in the EBO.
The important part is that the mesh data does not need to be uploaded again every frame. Once the buffers have been created, the CPU can issue another draw call using the data already stored on the GPU.
This reduces the amount of data that needs to move between the CPU and GPU and lets the GPU focus on repeatedly rendering the same geometry.
Of course, storing geometry on the GPU does not automatically make a renderer fast. The GPU still has to process every vertex and triangle that we ask it to draw.
That led to the next question I encountered while building the voxel engine:
What if most of the geometry being drawn cannot even be seen?
That became the first major optimization problem.