OpenTK Tutorial 2 - Drawing a Triangle... the Right Way!
The previous tutorial showed us how we can draw a triangle to the screen. However, it came with a disclaimer. Even though it works, it’s not the “correct” way to do things anymore. The way we sent our geometry to the GPU was what’s known as “immediate mode”, which isn’t the newest way to do things, even if it is so very nice and simple.
In this tutorial, we’ll be going for the same end goal, but we’ll be doing things in a way that is more complex, but at the same time more efficient, faster, and more expandable.
We’ll be starting out a lot like the previous tutorial, which I’ll be referencing a few times, so if you haven’t already, take a look at it.
Part 1: Setup
To start, we’ll need to make a new project file, with references to OpenTK and System.Drawing, as in the previous tutorial. Name it something like OpenTKTutorial2, for convenience.
Part 2: Coding
First we need to do some of the basics from the first tutorial again. Add a new class called “Game”. Make it a subclass of GameWindow (you’ll need to add a using directive for OpenTK to use the class).
You should have something like this:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using OpenTK;
namespace OpenTKTutorial2
{
class Game: GameWindow
{
}
}
Go back to Program.cs, and add the following code to the Main function:
using (Game game = new Game())
{
game.Run(30,30);
}
This is the same code to make a window and get it to pop up for us (set to update 30 times a second and display at 30 FPS). Now we’ll override onLoad again, so we can change the color and title for our window:
protected override void OnLoad(EventArgs e)
{
base.OnLoad(e);
Title = "Hello OpenTK!";
GL.ClearColor(Color.CornflowerBlue);
}
Override onRenderFrame, like before, so it’ll display our blue background:
protected override void OnRenderFrame(FrameEventArgs e)
{
base.OnRenderFrame(e);
GL.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
SwapBuffers();
}
Now we can get to the new stuff!
What we’ll need to do first is create our shaders. Shaders are how modern OpenGL knows how to draw the values it’s been given. We’ll be working with two kinds of shaders: a vertex shader and a fragment shader. The vertex shader tells the graphics code information about the points in the shape being drawn. The fragment shader determines what color each pixel of the shape will be when it’s drawn to the screen. The code we’ll be using is very simple, but it’ll let us draw similarly to immediate mode. OpenGL’s shaders are programmed in a C-like scripting language called GLSL (However, DirectX uses a slightly different language called HLSL).
Add a text file to your project called “vs.glsl”. This will store our vertex shader:
#version 330
in vec3 vPosition;
in vec3 vColor;
out vec4 color;
uniform mat4 modelview;
void
main()
{
gl_Position = modelview * vec4(vPosition, 1.0);
color = vec4( vColor, 1.0);
}
(Note: for the shader files, you’ll probably need to tell your IDE to copy them to your output directory. Otherwise, the program won’t be able to find them!)
The first line tells the linker which version of GLSL is being used.
The “in” lines refer to variables that are different for each vertex. “out” variables are sent to the next part of the graphics pipeline, where they’re interpolated to make a smooth transition across fragments. We’re sending on the color for each vertex. The “vec3” type refers to a vector with three values, and “vec4” is one with four values.
There’s also a “uniform” variable here, which is the same for the entire object being drawn. This will have our transformation matrix, so we can alter the vertices in our object at once. We won’t be using this quite yet, but we’ll be using it soon.
Our fragment shader is significantly simpler. Save the following as “fs.glsl”:
#version 330
in vec4 color;
out vec4 outputColor;
void
main()
{
outputColor = color;
}
It just takes the color variable from before (note that it’s an “in” insead of an “out” now), and sets the output to be that color.
Now that we have these shaders made, we need to tell the graphics card to use them. First, we’ll need to tell OpenGL to make a new program object. This will store our shaders together in a usable form.
First, define a variable for the program’s ID (its address), outside the scope of any of the functions. We don’t store the program object itself on our end of the code. We only have an address to refer to it by, as it stays on the graphics card.
int pgmID;
Make a new function in the Game class, called initProgram. In this function, we’ll start with a call to the GL.CreateProgram() function, which returns the ID for a new program object, which we’ll store in pgmID.
void initProgram()
{
pgmID = GL.CreateProgram();
}
Now we’re going to take a short detour to make a function that reads our shaders and adds them.
This function needs to take a filename and some information, and return the address for the shader that was created.
It should look something like this:
void loadShader(String filename,ShaderType type, int program, out int address)
{
address = GL.CreateShader(type);
using (StreamReader sr = new StreamReader(filename))
{
GL.ShaderSource(address, sr.ReadToEnd());
}
GL.CompileShader(address);
GL.AttachShader(program, address);
Console.WriteLine(GL.GetShaderInfoLog(address));
}
This creates a new shader (using a value from the ShaderType enum), loads code for it, compiles it, and adds it to our program. It also prints any errors it found to the console, which is really nice for when you make a mistake in a shader (it will also yell at you if you use deprecated code).
Now that we have this, let’s add our shaders. First we’ll need two more variables in our class:
int vsID;
int fsID;
These will store the addresses of our two shaders. Now, we’ll want to use the function we made to load our shaders from the files.
Add this code to initProgram:
loadShader("vs.glsl", ShaderType.VertexShader, pgmID, out vsID);
loadShader("fs.glsl", ShaderType.FragmentShader, pgmID, out fsID);
Now that the shaders are added, the program needs to be linked. Like C code, the code is first compiled, then linked, so that it goes from human-readable code to the machine language needed.
After the other code add:
GL.LinkProgram(pgmID);
Console.WriteLine(GL.GetProgramInfoLog(pgmID));
This will link it, and tell us if we have any errors.
The shaders are now added to our program, but we need to give our program more information before it can use them properly. We have multiple inputs on our vertex shader, so we need to get their addresses to give the shader position and color information for our vertices.
Add this code to the Game class:
int attribute_vcol;
int attribute_vpos;
int uniform_mview;
We’re defining three variables here, to store the locations of each variable, for future reference. Later we’ll need these values again, so we should keep them handy. (As a side note, I used the name “attribute” because in older versions of GLSL, instead of “in” variables, there were “attributes”.) To get the addresses for each variable, we use the GL.GetAttribLocation and GL.GetUniformLocation functions. Each takes the program’s ID and the name of the variable in the shader.
At the end of initProgram, add:
attribute_vpos = GL.GetAttribLocation(pgmID, "vPosition");
attribute_vcol = GL.GetAttribLocation(pgmID, "vColor");
uniform_mview = GL.GetUniformLocation(pgmID, "modelview");
if (attribute_vpos == -1 || attribute_vcol == -1 || uniform_mview == -1)
{
Console.WriteLine("Error binding attributes");
}
This code will get the values we need, and also do a simple check to make sure the attributes were found.
Now our shaders and program are set up, but we need to give them something to draw. To do this, we’ll be using a Vertex Buffer Object (VBO). When you use a VBO, first you need to have the graphics card create one, then bind to it and send your information. Then, when the DrawArrays function is called, the information in the buffers will be sent to the shaders and drawn to the screen.
Like with the shaders’ variables, we need to store the addresses for future use:
int vbo_position;
int vbo_color;
int vbo_mview;
Creating the buffers is very simple. In initProgram, add:
GL.GenBuffers(1, out vbo_position);
GL.GenBuffers(1, out vbo_color);
GL.GenBuffers(1, out vbo_mview);
This generates 3 separate buffers and stores their addresses in our variables. For multiple buffers like this, there’s an option for generating multiple buffers and storing them in an array, but for simplicity’s sake, we’re keeping them in separate ints.
These buffers are going to need some data next. The positions and colors will all be Vector3 variables, and the model view will be a Matrix4. We need to store these in an array so the data can be sent to the buffers more efficiently.
Add three more variables to the Game class:
Vector3[] vertdata;
Vector3[] coldata;
Matrix4[] mviewdata;
For this example, we’ll be setting these values in onLoad, along with a call to initProgram():
protected override void OnLoad(EventArgs e)
{
base.OnLoad(e);
initProgram();
vertdata = new Vector3[] { new Vector3(-0.8f, -0.8f, 0f),
new Vector3( 0.8f, -0.8f, 0f),
new Vector3( 0f, 0.8f, 0f)};
coldata = new Vector3[] { new Vector3(1f, 0f, 0f),
new Vector3( 0f, 0f, 1f),
new Vector3( 0f, 1f, 0f)};
mviewdata = new Matrix4[]{
Matrix4.Identity
};
Title = "Hello OpenTK!";
GL.ClearColor(Color.CornflowerBlue);
GL.PointSize(5f);
}
With the data stored, we can now send it to the buffers. We’ll need to add another override, this time for the OnUpdateFrame function. The first thing we need to do is bind to the buffer:
GL.BindBuffer(BufferTarget.ArrayBuffer, vbo_position);
This tells OpenGL that we’ll be using that buffer if we send any data to it. Next, we’ll actually send the data:
GL.BufferData<Vector3>(BufferTarget.ArrayBuffer, (IntPtr)(vertdata.Length * Vector3.SizeInBytes), vertdata, BufferUsageHint.StaticDraw);
This code tells it that we’re sending the contents of vertdata, which is (vertdata.Length * Vector3.SizeInBytes) bytes in length, to the buffer. Finally, we’ll need to tell it to use this buffer (the last one bound to) for the vPosition variable, which will take 3 floats:
GL.VertexAttribPointer(attribute_vpos, 3, VertexAttribPointerType.Float, false, 0, 0);
So, all together for both variables is:
GL.BindBuffer(BufferTarget.ArrayBuffer, vbo_position);
GL.BufferData<Vector3>(BufferTarget.ArrayBuffer, (IntPtr)(vertdata.Length * Vector3.SizeInBytes), vertdata, BufferUsageHint.StaticDraw);
GL.VertexAttribPointer(attribute_vpos, 3, VertexAttribPointerType.Float, false, 0, 0);
GL.BindBuffer(BufferTarget.ArrayBuffer, vbo_color);
GL.BufferData<Vector3>(BufferTarget.ArrayBuffer, (IntPtr)(coldata.Length * Vector3.SizeInBytes), coldata, BufferUsageHint.StaticDraw);
GL.VertexAttribPointer(attribute_vcol, 3, VertexAttribPointerType.Float, true, 0, 0);
We’ll also need to send the model view matrix, which uses a different function:
GL.UniformMatrix4(uniform_mview, false, ref mviewdata[0]);
Finally, we’ll want to clear the buffer binding and set it up to use the program with our shaders:
GL.UseProgram(pgmID);
GL.BindBuffer(BufferTarget.ArrayBuffer, 0);
We’re almost done! Now we have our data, our shaders, and the data being sent to the graphics card, but we still need to draw it. In our OnRenderFrame function, first we’ll need to tell it to use the variables we want:
GL.EnableVertexAttribArray(attribute_vpos);
GL.EnableVertexAttribArray(attribute_vcol);
Then we tell it how to draw them:
GL.DrawArrays(PrimitiveType.Triangles, 0, 3);
Then we do a bit to keep things clean:
GL.DisableVertexAttribArray(attribute_vpos);
GL.DisableVertexAttribArray(attribute_vcol);
GL.Flush();
So, all together, this function is:
protected override void OnRenderFrame(FrameEventArgs e)
{
base.OnRenderFrame(e);
GL.Viewport(0, 0, Width, Height);
GL.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
GL.Enable(EnableCap.DepthTest);
GL.EnableVertexAttribArray(attribute_vpos);
GL.EnableVertexAttribArray(attribute_vcol);
GL.DrawArrays(BeginMode.Triangles, 0, 3);
GL.DisableVertexAttribArray(attribute_vpos);
GL.DisableVertexAttribArray(attribute_vcol);
GL.Flush();
SwapBuffers();
}
Now, if you run this, you’ll see that after all this work, we’re (more or less) back where we were before.
