Skip to content

Step 1: The basic game loop using OpenTK

KAR edited this page Feb 24, 2020 · 12 revisions

What is OpenTK?

OpenTK is a package that allows us to use OpenGL commands in C#. It is a wrapper library that also provides a lot of math functions needed for graphics. You probably won't need any other libraries if you use OpenTK. OpenTK also offers game loop mechanics that work quite well.

Installation

  1. Create a Visual Studio console app (.NET or .NET Core - I used .NET Framework). Then add the NuGet package of OpenTK to your project.
  2. Create a new class (for example: MainWindow) and let it be a subclass of OpenTK's GameWindow class (you will need to add 'using OpenTK;' and 'using OpenTK.Graphics.OpenGL4;').
  3. Switch your application from "console" to "windows" afterwards.

Setup of the main window

using System;
using System.Diagnostics;
using OpenTK;
using OpenTK.Graphics;
using OpenTK.Graphics.OpenGL4;
using OpenTK.Input;
using System.Windows.Forms;

public sealed class MainWindow : GameWindow
{
        private Matrix4 _viewMatrix = new Matrix4();
        private Matrix4 _projectionMatrix = new Matrix4();
        private Matrix4 _viewProjectionMatrix = new Matrix4();

        private static MainWindow S_WINDOW;
        private int _defaultTextureId = -1;
        
        //You will create this class later:
        //private Renderer _renderProgram;

        // You will need a default texture for every object that has not been assigned one, yet.
        // An OpenGL texture is nothing more than an integer id.
        // Every new texture gets a different id. You setup a texture once and it will be in memory
        // until you explicitely delete it.
        public int DefaultTexture
        {
            get
            {
                return _defaultTextureId;
            }
        }

        // This is for referencing the main window from everywhere in your project
        public static MainWindow Window
        {
            get
            {
                return S_WINDOW;
            }
        }

        public MainWindow(int width, int height)
            : base(width,
                height,
                GraphicsMode.Default,
                "OpenGL2D Setup", 
                GameWindowFlags.Default,
                DisplayDevice.Default,
                4, 
                3,
                GraphicsContextFlags.ForwardCompatible)
        {
            Title += " (Version: " + GL.GetString(StringName.Version) + ")";
            S_WINDOW = this;

            X = Screen.PrimaryScreen.WorkingArea.Width / 2 - Width / 2;
            Y = Screen.PrimaryScreen.WorkingArea.Height / 2 - Height / 2;
        }

        protected override void OnUpdateFrame(FrameEventArgs e)
        {
            if (!Focused)
                return;

            KeyboardState keyboardState = Keyboard.GetState();
            MouseState mouseState = Mouse.GetState();

            if (keyboardState.IsKeyDown(Key.Escape))
            {
                Exit();
            }

            // Here you must do:
            // 1. Update the positions of your objects according to keyboard and mouse inputs
        }

        protected override void OnRenderFrame(FrameEventArgs e)
        {
            // Here you must do:
            // 1. Bind the correct framebuffer (the image that gets displayed on screen)
            // 2. Clear the previous image from last render
            // 3. Update your view matrix (camera) if the camera has moved
            // 4. Update/create a view-projection matrix (a mix of view and projection matrix)
            // 5. Draw every object in your game world in a loop
            
            // 6. Swap the default framebuffer's front and back buffer:
            //    (the front buffer is displayed while the back buffer is drawn and vice versa)
            SwapBuffers();
        }

        protected override void OnLoad(EventArgs e)
        {
            base.OnLoad(e);

            // Here you must do:
            // 1. Initialize shader programs
            // 2. Initialize basic geometry data (like the quads for 2d objects)
            // 3. Initialize camera (view matrix)
            // 4. Initialize screen (projection matrix)
            // 5. Call basic GL commands (default bgcolor, enable alpha channels, disable depth buffer, etc.)
        }

        protected override void OnResize(EventArgs e)
        {
            // this will adjust the framebuffer's viewport to fit the new screen size:
            GL.Viewport(this.ClientRectangle);

            // This will update the projection matrix to fit the new screen width and height:
            _projectionMatrix = Matrix4.CreateOrthographicOffCenter(0, Width, Height,0, 0.1f, 100f);
        }
}

MainWindow methods explained

MainWindow constructor method

You do not need to add a lot here. Basically this is just opening a window and then setting its X and Y position to a centered position (according to the screens bounds).

OnUpdateFrame method

This is for updating your game logic. For the basic setup process, this method needs no additions.

OnRenderFrame method

This needs the following lines to be added to the code:

1. Bind the correct Framebuffer

The frame buffer with id #0 is the default framebuffer: your screen.

GL.BindFramebuffer(FramebufferTarget.Framebuffer, 0);

2. Clear the image from last render pass

Clearing the depth buffer bit might not be needed here, because in a 2d environment nothing really is in front of anything. We'll set it anyway in case that we later want to add the third dimension:

GL.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);

3. Update view matrix (if camera has moved)

This is done by calling the static method 'LookAt'. It means: The camera is situated at (0|0|1) and it is looking at the point (0|0|0). The last three parameters tell our engine, that the y-Axis is "up" in our world (0, 1 ,0).

_viewMatrix = Matrix4.LookAt(0, 0, 1, 0, 0, 0, 0, 1 ,0);

See "What are matrices?" further down below.

4. Update/create a view-projection matrix

This is a matrix that contains two information in one: a) the view matrix and b) the projection matrix:

_viewProjectionMatrix = _viewMatrix * _projectionMatrix;

Multiplying matrices can be seen as "adding up" their individual information.

5. Draw every object in your world

We will skip this step and add this functionality later on.

6. Swap the default framebuffer's front and back buffer

SwapBuffers();

OnLoad method

The OnLoad() method gets called once at startup. It sets the basic options for drawing objects on the screen.

1. Initialize shader programs

See the Initialize shader programs wiki page for this.

2. Initialize basic geometry data

See the Initialize basic geometry wiki page for this.

3. Initialize camera (view matrix)

_viewMatrix = Matrix4.LookAt(0, 0, 1, 0, 0, 0, 0, 1 ,0);

If you never move the camera this line only needs to be set once in the OnLoad() method.

4. Initialize screen (projection matrix)

_projectionMatrix = Matrix4.CreateOrthographicOffCenter(0, Width, Height,0, 0.1f, 100f);

Normally, OpenGL thinks that your screen's sides have the same length (width = height). But since there is almost no screen out there with these dimensions, you'll need to tell OpenGL to compensate by calling the CreateOrthographicOffCenter() method. Basically, it needs to be given the screen's boundarys (0 = left, Width = right, Height = bottom, 0 = top) as well as the nearest and farest distance from the camera at which objects will still be drawn. In 2D games, this can be from 0.1f to 100.0f. If you set z-Far (last parameter) to 100, your camera will see every object that is <100 units away from it.

5. Call basic GL commands `

If you use images with transparency (gif or png) you will need to tell OpenGL to take the alpha channel into consideration:

GL.Enable(EnableCap.Blend);
GL.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);

In 2D mode, we do not need OpenGL to check whether an object is in front of another object. In fact, because every object has the same distance to our camera, there can be excessive flickering ("z fighting") if we do enable the depth test because the GPU does not know which object is in front of which if the distance is the same for every object. So, eventually we simply disable the depth buffer. If disabled, the last rendered object will always be in front.

GL.Disable(EnableCap.DepthTest);

And here we define the color of the screen's background if no object is drawn in front of it.

Color4 backColor = new Color4();
backColor.A = 1.0f;             // alpha = 1, completely visible
backColor.R = (62.0f / 255.0f); // example value
backColor.G = (27.0f / 255.0f); // example value
backColor.B = (89.0f / 255.0f); // example value
GL.ClearColor(backColor);

What are matrices (and why do I need them)?

There are three types of matrices in this project:

  1. view matrix (storing the camera's position and view direction)
  2. projection matrix (storing your screen's width and height and the draw distance)
  3. model matrix (storing the way an object is manipulated by changing its position, rotation and scale)

We already talked about the first two matrices. But what is the model matrix? Every object in OpenGL is drawn from primitives (like triangles). If you move the object, you would need to move the triangles' positions as well. If you have one triangle, you would need to move three points. But if your model had 1000 triangles, you would need to change the values for 3000 points. This is hard work for the cpu, but almost no effort for your graphics card. So, instead of moving the points all by ourselves, we just pass the model matrix to the gpu and let it do all the work. The model matrix contains information about how an object needs to be scaled, rotated and moved (in this exact order). You can build a model matrix by multiplying scale, rotation and movement (also called translation):

Matrix4 modelMatrix = Matrix4.CreateScale(...) * Matrix4.CreateRotationZ(...) * Matrix4.CreateTranslation(x, y, z);

When multiplying matrices, you add up their effects. But the multiplication needs to be in this exact order or it will not work the way you want it to. Later you just pass a model-view-projection matrix to the gpu and let it multiply each point with this matrix. The model-view-projection matrix contains the combined information of model, view and projection matrices - all in one matrix!