OpenGL 6: Down The Catwalk
Triangles, Quads and Cubes are pretty useful primitives but they’re pretty basic. To improve this we’ll cover loading models from files. We’ll also cover some of the popular formats seein in graphics and look at how to use them with C#. There’s so many formats to cover I’m not even going to come close to mentioning them all. You’ll need to do your own research on what formats your application should support.
You’ll need some files for this one: monkey.obj and monkey.glb.
Lets take a quick look at the result of this one - does it look familiar? This is the first example we looked at way back in Chapter 0.

Model Keeping
Before we dive into importing new models we should take some time to tidy up our existing data. We want to keep the cube for future use; having a library of basic shapes, called primitives, is very useful. Create a new class Cube
and move everything over:
using OpenTK.Graphics.OpenGL;
using OpenTK.Mathematics;
class Cube
{
Vector3[] verticies = [
(-0.5f, -0.5f, -0.5f), (0.5f, -0.5f, -0.5f),(0.5f, 0.5f, -0.5f),
(0.5f, 0.5f, -0.5f), (-0.5f, 0.5f, -0.5f),(-0.5f, -0.5f, -0.5f),
(-0.5f, -0.5f, 0.5f), (0.5f, -0.5f, 0.5f),(0.5f, 0.5f, 0.5f),
(0.5f, 0.5f, 0.5f), (-0.5f, 0.5f, 0.5f),(-0.5f, -0.5f, 0.5f),
(-0.5f, 0.5f, 0.5f), (-0.5f, 0.5f, -0.5f),(-0.5f, -0.5f, -0.5f),
(-0.5f, -0.5f, -0.5f), (-0.5f, -0.5f, 0.5f),(-0.5f, 0.5f, 0.5f),
(0.5f, 0.5f, 0.5f), (0.5f, 0.5f, -0.5f),(0.5f, -0.5f, -0.5f),
(0.5f, -0.5f, -0.5f), (0.5f, -0.5f, 0.5f),(0.5f, 0.5f, 0.5f),
(-0.5f, -0.5f, -0.5f), (0.5f, -0.5f, -0.5f),(0.5f, -0.5f, 0.5f),
(0.5f, -0.5f, 0.5f), (-0.5f, -0.5f, 0.5f),(-0.5f, -0.5f, -0.5f),
(-0.5f, 0.5f, -0.5f), (0.5f, 0.5f, -0.5f),(0.5f, 0.5f, 0.5f),
(0.5f, 0.5f, 0.5f), (-0.5f, 0.5f, 0.5f),(-0.5f, 0.5f, -0.5f),
];
Vector2[] uvCoords = [
(0.0f, 0.0f), (1.0f, 0.0f), (1.0f, 1.0f),
(1.0f, 1.0f), (0.0f, 1.0f), (0.0f, 0.0f),
(0.0f, 0.0f), (1.0f, 0.0f), (1.0f, 1.0f),
(1.0f, 1.0f), (0.0f, 1.0f), (0.0f, 0.0f),
(1.0f, 0.0f), (1.0f, 1.0f), (0.0f, 1.0f),
(0.0f, 1.0f), (0.0f, 0.0f), (1.0f, 0.0f),
(1.0f, 0.0f), (1.0f, 1.0f), (0.0f, 1.0f),
(0.0f, 1.0f), (0.0f, 0.0f), (1.0f, 0.0f),
(0.0f, 1.0f), (1.0f, 1.0f), (1.0f, 0.0f),
(1.0f, 0.0f), (0.0f, 0.0f), (0.0f, 1.0f),
(0.0f, 1.0f), (1.0f, 1.0f), (1.0f, 0.0f),
(1.0f, 0.0f), (0.0f, 0.0f), (0.0f, 1.0f),
];
int vao;
private float[] BuildVertexArray()
{
float[] data = new float[verticies.Length * 5];
int i = 0;
for(int d = 0; d < data.Length; d+= 5)
{
data[d + 0] = verticies[i].X;
data[d + 1] = verticies[i].Y;
data[d + 2] = verticies[i].Z;
data[d + 3] = uvCoords[i].X;
data[d + 4] = uvCoords[i].Y;
i++;
}
return data;
}
public void BindVao(int shaderId)
{
vao = GL.GenVertexArray();
GL.BindVertexArray(vao);
float[] data = BuildVertexArray();
int vbo = GL.GenBuffer();
GL.BindBuffer(BufferTarget.ArrayBuffer, vbo);
GL.BufferData(BufferTarget.ArrayBuffer, data.Length * sizeof(float) * 5, data, BufferUsage.StaticDraw);
uint position = (uint)GL.GetAttribLocation(shaderId, "position");
GL.EnableVertexAttribArray(position);
GL.VertexAttribPointer(position, 3, VertexAttribPointerType.Float, false, sizeof(float) * 5, 0);
uint uv = (uint)GL.GetAttribLocation(shaderId, "uv");
GL.EnableVertexAttribArray(uv);
GL.VertexAttribPointer(uv, 2, VertexAttribPointerType.Float, false, sizeof(float) * 5, sizeof(float) * 3);
}
public int Vao => vao;
public int VertexCount => verticies.Length;
}
Now we can tidy up our setup code:
// setup code
Shader shader = new ();
shader.Setup();
Cube cube = new ();
cube.BindVao(shader.Id);
Texture texture = new ();
// https://polyhaven.com/a/distressed_painted_planks
texture.Buffer(File.Open("distressed_painted_planks_diff_1k.png", FileMode.Open));
int uRotation = GL.GetUniformLocation(shader.Id, "rotation");
int uProjection = GL.GetUniformLocation(shader.Id, "projection");
int uView = GL.GetUniformLocation(shader.Id, "view");
GL.ClearColor(0.2f, 0.3f, 0.4f, 1.0f);
GL.Enable(EnableCap.DepthTest);
while (true)
Note that we’re only pulling the vertex data out. We only need one copy of the cube on the GPU and can change what position or texture it is before each draw.
Surf The Wave
We’ll start with a very popular format for distributing model and material data: Wavefront or .obj
. This stalwart format was created in the 80’s by Wavefront for their The Advanced Visualizer product. It’s a text format designed for ASCII which makes writing importers an approachable task but we’re going to pick an existing one.
We’re going to pick Jeremy Ansel’s package to avoid the complexity of multi-format packages. As usual we’ll create a new class to wrap this up for us.
using JeremyAnsel.Media.WavefrontObj;
using OpenTK.Graphics.OpenGL;
class Wavefront
{
float[] data;
int vao;
int vertexCount;
public void Load(string file)
{
ObjFile obj = ObjFile.FromFile(file);
vertexCount = obj.Faces.Count * 3;
data = new float[vertexCount * 5];
int i = 0;
for (int v = 0; v < obj.Faces.Count; v++)
{
for (int f = 0; f < obj.Faces[v].Vertices.Count; f++)
{
ObjTriplet triplet = obj.Faces[v].Vertices[f];
data[i++] = obj.Vertices[triplet.Vertex - 1].Position.X;
data[i++] = obj.Vertices[triplet.Vertex - 1].Position.Y;
data[i++] = obj.Vertices[triplet.Vertex - 1].Position.Z;
data[i++] = obj.TextureVertices[triplet.Texture - 1].X;
data[i++] = obj.TextureVertices[triplet.Texture - 1].Y;
}
}
}
public void BindVao(int shaderId)
{
vao = GL.GenVertexArray();
GL.BindVertexArray(vao);
int vbo = GL.GenBuffer();
GL.BindBuffer(BufferTarget.ArrayBuffer, vbo);
GL.BufferData(BufferTarget.ArrayBuffer, data.Length * sizeof(float), data, BufferUsage.StaticDraw);
uint position = (uint)GL.GetAttribLocation(shaderId, "position");
GL.EnableVertexAttribArray(position);
GL.VertexAttribPointer(position, 3, VertexAttribPointerType.Float, false, sizeof(float) * 5, 0);
uint uv = (uint)GL.GetAttribLocation(shaderId, "uv");
GL.EnableVertexAttribArray(uv);
GL.VertexAttribPointer(uv, 2, VertexAttribPointerType.Float, false, sizeof(float) * 5, sizeof(float) * 3);
}
public int Vao => vao;
public int VertexCount => vertexCount;
}
Wavefront describes objects with Faces
that reference a 1 indexed vertex or texture value. We build a float[] data
to house our data as with the cube but this time we go through Faces
then for each face every Vertex
. The file format allows any size faces, not just triangles, so if you are exporting your own Obj files make sure to triangulate. We build the same vertex array as before, 3 verticies and 2 texture UVs.
To give ourselves something to load I’m going to use Blender’s Suzanne test model. Grab a copy and save it in your project folder next to the texture file we added back in Chapter 4.
Using our new model is straightforwards back in our setup code. Create a new instance of the Wavefront
class then Load
and BindVao
. You’ll need a string with the name of the download: mine is called “monkey.obj”.
Wavefront monkey = new ();
monkey.Load("monkey.obj");
monkey.BindVao(shader.Id);
We can now replace our draw call with this new object: GL.DrawArrays(PrimitiveType.Triangles, 0, monkey.VertexCount);
. With a bit of luck you should now be staring down a plank suzanne!

Another One
Now we have two VAOs it’s high time we looked at how to see them side by side. If you try and call DrawArrays
with the cube
then the monkey
you’ll find your program either crashes or only draws a tiny part of the same object. Cast your mind all the way back to Chapter 2 when the VAO was introduced the following was explained:
OpenGL only allows one VAO to be bound and all commands referencing it rely on this.
When we create a new VAO we bind it immediately so we can configure our VBO data after which we don’t think about it. This results in whichever model we bind last being the active VAO for our draw command. We need to manage the active VAO before each draw. In our simple program we can call BindVertexArray
before we attempt to draw each object:
GL.BindVertexArray(monkey.Vao);
GL.DrawArrays(PrimitiveType.Triangles, 0, monkey.VertexCount);
GL.BindVertexArray(cube.Vao);
GL.DrawArrays(PrimitiveType.Triangles, 0, cube.VertexCount);
One thing to note here is there’s no UnBind
function for Vertex Arrays. Generally leaving the last VAO bound is fine but if you need to ensure a VAO is no longer active you can bind 0
which is defined as always being empty. With that out of the way we can enjoy our cube and Suzanne together.

As beautiful as this is it’d be a lot more useful to place different objects in different places. To do that we need to update the translation
matrix we send to the shader. Shader uniforms are kept as long as the shader is bound so we only need to update the translation
value - view
and projection
can stay. With this knowledge in hand we can build two transform matricies and send them as required. With this we now have the foundation to draw any complex scene.
GL.UniformMatrix4f(uProjection, 1, true, camera.Projection);
GL.UniformMatrix4f(uView, 1, true, camera.View);
Matrix4 translation = Matrix4.CreateRotationX(-2.2f)
* Matrix4.CreateRotationY(0.7f);
GL.UniformMatrix4f(uTranslation, 1, true, ref translation);
GL.BindVertexArray(monkey.Vao);
GL.DrawArrays(PrimitiveType.Triangles, 0, monkey.VertexCount);
translation = Matrix4.CreateRotationX(2.2f)
* Matrix4.CreateRotationY(0.7f)
* Matrix4.CreateTranslation(2f, 0f, 0f);
GL.UniformMatrix4f(uTranslation, 1, true, ref translation);
GL.BindVertexArray(cube.Vao);
GL.DrawArrays(PrimitiveType.Triangles, 0, cube.VertexCount);

Index Transmission
The next format we’re goign to look at is glTF or Graphics Library Transmission Format. That’s what the GL in OpenGL stands for if you didn’t know! GLTF is an open standard published by Khronos Group who are also the owners of the OpenGL Standard so we know we’re in good hands. Unlike OBJ which typically only contains individual objects glTF can be used for entire scenes.
This time around we’ll use Khronos Group’s example C# glTF package to load our data. It’s a fairly barebones package which will be good for demonstrating how data loading is done. As usual we’ll drop this into its own class:
using System.Buffers.Binary;
using glTFLoader;
using glTFLoader.Schema;
using OpenTK.Graphics.OpenGL;
class GltfModel
{
float[] data;
ushort[] indicies;
int vao;
int vertexCount;
public void Load(string file)
{
Gltf model = Interface.LoadModel(file);
Span<byte> buffer = Interface.LoadBinaryBuffer(file).AsSpan();
Mesh mesh = model.Meshes[0];
MeshPrimitive primitive = mesh.Primitives[0];
Accessor position = model.Accessors[primitive.Attributes["POSITION"]];
Accessor texture = model.Accessors[primitive.Attributes["TEXCOORD_0"]];
data = new float[position.Count * 5];
BufferView positionView = model.BufferViews[position.BufferView!.Value];
BufferView textureView = model.BufferViews[texture.BufferView!.Value];
float ReadSingle(ref Span<byte> buffer, int index)
{
return BinaryPrimitives.ReadSingleLittleEndian(buffer.Slice(index, 4));
}
int p = positionView.ByteOffset;
int t = textureView.ByteOffset;
for(int i = 0; i < position.Count * 5; i += 5)
{
data[i + 0] = ReadSingle(ref buffer, p + 0);
data[i + 1] = ReadSingle(ref buffer, p + 4);
data[i + 2] = ReadSingle(ref buffer, p + 8);
p += 12;
data[i + 3] = ReadSingle(ref buffer, t + 0);
data[i + 4] = ReadSingle(ref buffer, t + 4);
t += 8;
}
BufferView indiciesView = model.BufferViews[primitive.Indices!.Value];
indicies = new ushort[indiciesView.ByteLength / sizeof(ushort)];
for(int i = 0; i < indicies.Length; i++)
{
indicies[i] = BinaryPrimitives.ReadUInt16LittleEndian(buffer.Slice(indiciesView.ByteOffset + i * sizeof(ushort)));
}
vertexCount = indicies.Length;
}
public void BindVao(int shaderId)
{
vao = GL.GenVertexArray();
GL.BindVertexArray(vao);
int vbo = GL.GenBuffer();
GL.BindBuffer(BufferTarget.ArrayBuffer, vbo);
GL.BufferData(BufferTarget.ArrayBuffer, data.Length * sizeof(float), data, BufferUsage.StaticDraw);
uint position = (uint)GL.GetAttribLocation(shaderId, "position");
GL.EnableVertexAttribArray(position);
GL.VertexAttribPointer(position, 3, VertexAttribPointerType.Float, false, sizeof(float) * 5, 0);
uint uv = (uint)GL.GetAttribLocation(shaderId, "uv");
GL.EnableVertexAttribArray(uv);
GL.VertexAttribPointer(uv, 2, VertexAttribPointerType.Float, false, sizeof(float) * 5, sizeof(float) * 3);
int ebo = GL.GenBuffer();
GL.BindBuffer(BufferTarget.ElementArrayBuffer, ebo);
GL.BufferData(BufferTarget.ElementArrayBuffer, indicies.Length * sizeof(ushort), indicies, BufferUsage.StaticDraw);
}
public int Vao => vao;
public int VertexCount => vertexCount;
}
That’s quite a bit chunkier than the OBJ class! We’ve got two important things to look at - loading the data and how to render it. We’ll take a closer look at loading the data first which starts off with how we load the file:
Gltf model = Interface.LoadModel(file);
Span<byte> buffer = Interface.LoadBinaryBuffer(file).AsSpan();
glTF separates out the scene description from the model data which for big scenes is a huge advantage but for our little model is a bit overkill. In the real world you’ll likely see .glt
files with separate .bin
data packs; we’re workign with a .glb
file that combines the two. In code we see this play out as we load the same file twice to read the metadata out then the binary data.
Once we have our model
and buffer
the rest of the code is figuring out where data lives and packing it into a VAO just like before. There’s something else happening before we’re done loading our model which is critical to getting it to work. Unlike our previous vertex arrays the data we have currently is Indexed. If we skipped the rest of this section we’d get a model that looks like this!

Indexed data allows for points to be re-used when constructing triangles. I’ll skip over the full explanation to say we need to fill out an Element Buffer
for it to work. Here in the Load
method that means creating a second ushort[]
array to hold our index data in and filling it with data from the main buffer
. We also set the VertexCount
value based on the length of indicies not the real count of verticies.
Now we have two arrays of data we move on to building our VAO. The VBO is built in the usual way before we move on to creating a new buffer: the ElementArrayBuffer
. This buffer is responsible for holding indicies so that’s what we fill it with! Aside from the different target make sure we use ushort
when calculating the length.
GL.BindVertexArray(model.Vao);
GL.DrawElements(PrimitiveType.Triangles, model.VertexCount, DrawElementsType.UnsignedShort, 0);
Finally we can change our Draw command to use DrawElements
which unsurprisingly looks to the ElementArrayBuffer
we just filled. There’s an extra property to describe the data size as draw has no idea what the incoming buffer will contain and a nint
(pointer) offset property that we don’t use in modern OpenGL. With everything done correctly we can see Suzanne again!

Recap
Loading models is one of the key steps from toy renderers to useful graphics applications. It’s worth keeping your cube class and expanding your collection of primitives that don’t have external dependencies. Common meshes are spheres, cylinders and quads. We already built a quad before so that one’s free so can you add the others?
We also looked at indexed rendering which provides a form of compression by allowing vertex data to be re-used. Simple geometry doesn’t benefit much, if at all, from indexed rendering but the memory saved with large models is significant. Indexed rendering isn’t the only technique that uses multiple buffers in the VAO and hopefully has helped demonstrate how it works.
Where Next?
Congratulations on making it this far! You haven’t just skipped to the end I hope… We’ve gotten familiar with all of the fundamental building blocks of graphics programming. Although this sounds like you’re ready to create anything the reality is you’re now ready to learn the beginner topics. From here I recommend firstly playing around with your own project and trying out whatever ideas you have. Alongside that check out some of the more in-depth series which will round out your knowledge covering the maths behind what we’ve done:
You’ll notice that most tutorials only cover the basics and some intermediate topics but rarely go on to advanced techniques found in modern AAA games. While some studios might want to keep their tricks to themselves the reality is advanced graphics programming is difficult and explaining just one techinque can require a lot of work. The best place to learn about these sorts of works are from SIGGRAPH whitepapers but those can sometimes be impenetrable even for experts. Blogs of game programmers are also fantastic if you can find them. I’ve collected a few notable ones here:
Finally if you’re going on to make a game you’ll want to check out some of these as well:
With all that handed over it’s just left for me to say good luck and make sure to have fun!
Originally published on 2025/04/28