Skip to content

Hello Triangle: The easy way

ThomasMiz edited this page Jun 17, 2022 · 3 revisions

In this tutorial, we'll see how to draw a single colored triangle the simplest way TrippyGL provides.

This tutorial is built on top of the previous tutorial on how to open a window, so if you haven't completed it or don't know how to open a window, take a look at it first.

OpenGL Pipeline

Before drawing a triangle, it is important that we understand how to draw a triangle. For this, we must understand the basics of the OpenGL pipeline.

If you're unfamiliar with the OpenGL pipeline, I recommend this official article from the Khronos Group that goes over the basic concepts without diving into code. In summary, the pipeline is composed of the following stages:

  • Vertex Specification: Specifies from which buffer to read vertex data, how it is laid out in memory, how to interpret it.
  • Vertex Processing: All vertices go through the Vertex Shader. This is a typically small function that can, among other things, modify the position of the vertices to move your object around the world.
  • Primitive Assembly: Your vertices are assembled into primitives. Primitives in OpenGL can be points, lines, triangles, quads, and more.
  • Rasterization: The GPU fills in the area of the framebuffer occupied by each primitive, and to determine the color of each pixel a Fragment Shader is invoked.

For rasterization, it is important to define how vertex positions map to coordinates on the viewport. In OpenGL, this is done with normalized coordinates. The bottom-left of the viewport is at the coordinate (-1, -1), the top-right at (1, 1), top-left at (-1, 1), and bottom-right at (1, -1). This leaves the (0, 0) at the center of the viewport.

With this in mind, let's get right to it!

Loading your vertices

For Vertex Specification, we need to load our vertex data into GPU memory and tell the pipeline how that data should be read. For this, we will use TrippyGL's VertexBuffer<T>, where T is the type of vertex to use. TrippyGL provides multiple built-in vertex types, such as:

  • VertexPosition (vec3 position)
  • VertexColor (vec3 position, color4b color)
  • VertexNormalTexture (vec3 position, vec3 normal, vec2 texCoord)

And more! We will be using VertexColor, since we just need position and color.

To start, we need to have our vertex data somewhere in CPU memory so it can be copied over. Since we're just playing with a few vertices, we'll just create an array with these. To position your vertices, remember how we previously defined viewport coordinates before as normalized coordinates! I want my triangle to be centered on the screen and occupy half the width and height of the screen so we can see it nice and big. I'll color it with Red, Green and Blue:

VertexColor[] vertexData = new VertexColor[]
{
    new VertexColor(new Vector3(-0.5f, -0.5f, 0), Color4b.Red),
    new VertexColor(new Vector3(0, 0.5f, 0), Color4b.Lime),
    new VertexColor(new Vector3(0.5f, -0.5f, 0), Color4b.Blue)
};

Create your array in your Window_Load() method. Also make sure you add an using System.Numerics; statement at the top of your file to bring the Vector3 type.

Next, add a VertexBuffer<VertexColor> variable to your program, the same way we have the graphicsDevice variable:

class Program
{
    static IWindow window;
    static GL gl;
    static GraphicsDevice graphicsDevice;

    static VertexBuffer<VertexColor> myBuffer;
    ...

Now in Window_Load(), after creating our vertexData array, we can create our VertexBuffer<VertexColor> like so:

myBuffer = new VertexBuffer<VertexColor>(graphicsDevice, vertexData, BufferUsage.StaticDraw);

There are multiple constructors you can use for VertexBuffer<T>. Some allow you to create it without specifying initial storage contents, others allow the specification of an index buffer, this one in particular allows us to pass an array for initial contents, and also uses this array's length as the buffer's size. An equivalent way to write this is like so:

myBuffer = new VertexBuffer<VertexColor>(graphicsDevice, (uint)vertexData.Length, BufferUsage.StaticDraw);
myBuffer.DataSubset.SetData(vertexData);

You might be wondering what is that last parameter set to BufferUsage.StaticDraw. Buffer usage hints are values that can be specified to tell the graphics driver how the buffer will be used so it can better optimize. Don't worry too much about this value, it is perfectly legal for the driver to just ignore it. We're setting to StaticDraw because:

  • Static: The CPU will only set the data once.
  • Draw: The CPU will not read the data back, but the GPU will use the data for drawing.

If however, you wanted to set the contents of your buffer every frame, then you'd use BufferUsage.StreamDraw. For more information on these, read the Buffer Object Usage section of the official article on Buffer Objects.

Finally, let's make sure your buffer is disposed after we're done using it! Let's dispose it when the window is closing, before the GraphicsDevice:

private static void Window_Closing()
{
    myBuffer.Dispose();
    ...

Congratulations! Your vertices are now in GPU memory. Time to draw them!

Create a ShaderProgram

Shaders are typically small programs that run on the GPU, as part of the graphics pipeline. We need a shader program with two shaders: a Vertex Shader and a Fragment Shader. In TrippyGL, shader programs are represented by the ShaderProgram class.

With bare OpenGL 3.3, you'd have to write your own shader even for simple cases. TrippyGL however brings you the SimpleShaderProgram class. This class can write a pre-made shader that can cover most simple usecases, it can even handle some basic 3D lighting!

First things first, let's add a SimpleShaderProgram variable, just like we did before:

class Program
{
    static IWindow window;
    static GL gl;
    static GraphicsDevice graphicsDevice;

    static VertexBuffer<VertexColor> myBuffer;
    static SimpleShaderProgram myProgram;
    ...

To create a SimpleShaderProgram for our VertexBuffer<VertexColor>, we can use this simple function inside our Window_Load() method:

myProgram = SimpleShaderProgram.Create<VertexColor>(graphicsDevice);

This will create a SimpleShaderProgram and attach to it a simple vertex shader and a fragment shader with vertex colors and no texture sampling.

And finally, let's dispose of this resource on our Window_Closing():

private static void Window_Closing()
{
    myBuffer.Dispose();
    myProgram.Dispose();
    ...

Putting everything together

Now we're going to our Window_Render() method to write the code that needs to run each frame in order to draw our triangle to the screen.

We need to tell our GraphicsDevice what buffer to read vertices from, which shader program to draw them with, and then make the draw call. The code for this is very simple:

private static void Window_Render(double dt)
{
    graphicsDevice.ClearColor = Color4b.Black;
    graphicsDevice.Clear(ClearBuffers.Color);

    graphicsDevice.VertexArray = myBuffer;
    graphicsDevice.ShaderProgram = myProgram;
    graphicsDevice.DrawArrays(TrippyGL.PrimitiveType.Triangles, 0, myBuffer.StorageLength);
}

GraphicsDevice.DrawArrays() takes in three parameters:

  • primitiveType: Which type of primitive to render.
  • startIndex: The index of the first vertex in the buffer to render
  • count: The amount of vertices after startIndex to render.

Also notice that I changed the clear color to black, so there is more contrast between our colorful triangle and the background.

That's it! This should draw a colored triangle.

using System.Numerics;
using Silk.NET.Maths;
using Silk.NET.OpenGL;
using Silk.NET.Windowing;
using TrippyGL;

namespace TrippyExample
{
    class Program
    {
        static IWindow window;
        static GL gl;
        static GraphicsDevice graphicsDevice;

        static VertexBuffer<VertexColor> myBuffer;
        static SimpleShaderProgram myProgram;

        static void Main(string[] args)
        {
            WindowOptions windowOpts = WindowOptions.Default;
            windowOpts.Title = "My TrippyGL Window!";
            windowOpts.API = new GraphicsAPI(ContextAPI.OpenGL, ContextProfile.Core, ContextFlags.Debug, new APIVersion(3, 3));

            using IWindow myWindow = Window.Create(windowOpts);
            window = myWindow;

            window.Load += Window_Load;
            window.Render += Window_Render;
            window.FramebufferResize += Window_FramebufferResize;
            window.Closing += Window_Closing;

            window.Run();
        }

        private static void Window_Load()
        {
            gl = window.CreateOpenGL();
            graphicsDevice = new GraphicsDevice(gl);

            VertexColor[] vertexData = new VertexColor[]
            {
                new VertexColor(new Vector3(-0.5f, -0.5f, 0), Color4b.Red),
                new VertexColor(new Vector3(0, 0.5f, 0), Color4b.Lime),
                new VertexColor(new Vector3(0.5f, -0.5f, 0), Color4b.Blue)
            };

            myBuffer = new VertexBuffer<VertexColor>(graphicsDevice, vertexData, BufferUsage.StaticDraw);

            myProgram = SimpleShaderProgram.Create<VertexColor>(graphicsDevice);

            Window_FramebufferResize(window.FramebufferSize);
        }

        private static void Window_Render(double dt)
        {
            graphicsDevice.ClearColor = Color4b.Black;
            graphicsDevice.Clear(ClearBuffers.Color);

            graphicsDevice.VertexArray = myBuffer;
            graphicsDevice.ShaderProgram = myProgram;
            graphicsDevice.DrawArrays(TrippyGL.PrimitiveType.Triangles, 0, myBuffer.StorageLength);
        }

        private static void Window_FramebufferResize(Vector2D<int> size)
        {
            graphicsDevice.SetViewport(0, 0, (uint)size.X, (uint)size.Y);
        }

        private static void Window_Closing()
        {
            myBuffer.Dispose();
            myProgram.Dispose();
            graphicsDevice.Dispose();
            gl.Dispose();
        }
    }
}

Excellent! If you got that working and want to experiment, try the following challenges:

  • Change the position of the vertices to invert the triangle.
  • Make a right-angled triangle!
  • Add more vertices, make more shapes. Experiment with different primitive types, such as PrimitiveType.TriangleStrip, PrimitiveType.LineStrip, and PrimitiveType.TriangleFan.
  • Change myBuffer's usage hint on creation to StreamDraw and update the vertices with different positions each frame! And what about different colors?
  • If you got the last one working, can you use the Silk.NET.Input package to get a vertex to follow the mouse cursor?

Troubleshooting

  • It could be a depth testing issue. Set your windowOpts.PreferredDepthBufferBits to 0 before Window.Create() is called. At the end of your Window_Load(), add a graphicsDevice.DepthState = DepthState.None;.
  • Make sure your blending settings are correct by adding graphicsDevice.BlendState = BlendState.Opaque; at the end of your Window_Load().
  • Face culling should be disabled. Make sure graphicsDevice.FaceCullingEnabled is set to false.

Additional reading

What is a VertexBuffer<T>?

VertexBuffer<T> is a helper type provided by TrippyGL that combines vertex storage and vertex specification into a single, easy-to-use package that covers most simple usecases. Behind the scenes, VertexBuffer<T> is creating: a BufferObject in which to store the vertex data, a VertexDataBufferSubset<T> which points to the entire BufferObject as a source for vertex data, and a VertexArray which is the object in charge of vertex specification.

VertexBuffer<T> is able to configure all of these automatically because the type of vertex it uses, T, must be an unmanaged struct that implements IVertex. The IVertex interface defines a way for a type to expose a list of attributes it contains. And yes! You can implement your own! ;)

This does mean however, that VertexBuffer<T> has the following restrictions:

  • Vertex data for a mesh may only be stored in a single buffer.
  • Vertex format must have interleaved attributes.

Let's say that you want your mesh to be constantly changing colors, and those colors need to be calculated on the CPU. It would make more sense if you could update just the colors on one buffer, rather than also have to set the positions again, right? For this, you'd create two buffers; one holds the positions while the other holds the colors. This is impossible to do with VertexBuffer<T>.

That said, all the functionality used internally by VertexBuffer<T> is exposed by TrippyGL. You may use all these objects yourself to create more complex vertex specification code.

What is a SimpleShaderProgram?

If you wanted to write your own shaders, you could write GLSL code and construct a ShaderProgram instance with it. This however is impractical for simple usecases, and that's where SimpleShaderProgram comes in, where you don't have to write any GLSL code.

We previously created an instance using SimpleShaderProgram.Create<T>. This however, is not the only way we can create SimpleShaderProgram instances!

To see the full power of SimpleShaderProgram, you may instead prefer to construct it using a SimpleShaderProgramBuilder. This is an equivalent way of constructing a SimpleShaderProgram:

// myProgram = SimpleShaderProgram.Create<VertexColor>(graphicsDevice);
SimpleShaderProgramBuilder builder = new()
{
    VertexColorsEnabled = true,
    TextureEnabled = false
};
builder.ConfigureVertexAttribs<VertexColor>();
myProgram = builder.Create(graphicsDevice);

Or equivalent:

SimpleShaderProgramBuilder builder = new()
{
    VertexColorsEnabled = true,
    TextureEnabled = false,
    PositionAttributeIndex = 0,
    ColorAttributeIndex = 1,
    NormalAttributeIndex = -1,
    TexCoordsAttributeIndex = -1,
};
myProgram = builder.Create(graphicsDevice);

For examples on what SimpleShaderProgram can do in 3D, check out the SimpleShader3D test project! Everything but the background is drawn with SimpleShaderProgram.

img_lighting.png