Search code examples
c#openglopentkvertex-shaderopengl-4

OpenGL (with OpenTK) texture doesn't render (not even a white rect)


I am doing on overhaul on a game engine from my university and I've hit a brick wall when dealing with OpenGL rendering. I'm using OpenTK with GLFW.NET to render to a window. (I recently switched to OpenTK and didn't bother changing to their GLFW instead of GLFW.NET, but I will probably do that in the future)

The code I'll post here is taken out of the containing classes, but I've also tested it as it is and it's still not working. It is not an issue of GLFW or linking the procAddress, as I've managed to render a triangle using the window context.

In the current trial at drawing an image there are no GL error codes.

GLContext.cs
This is called by a GameProcess and the Run() method is used after a window is created.

private Texture2D tex;

public ShaderProgram shader;
public VertexArrayObject vao;
public VertexBufferObject vbo;
public ElementBufferObject ebo;

Matrix4 viewMatrix;
Matrix4 orthoMatrix;

private readonly uint[] indices =
    {
        0, 1, 3,
        1, 2, 3
    };

public override void Run() {
    // perform gl setup
    GL.Enable(EnableCap.DepthTest);
    GL.Enable(EnableCap.Multisample);
    //GL.Enable(EnableCap.Texture2D);
    GL.Enable(EnableCap.Blend);

    GL.ClearColor(0.0f, 0.0f, 0.0f, 0.0f);

    tex = new Texture2D(new Bitmap("Assets/Sprites/Test.png")); // This image exists
    tex.Load();

    vao = new VertexArrayObject();
    vao.Bind();

    float[] vertices = new float[]
    {
        // Position         Texture coordinates
         0.5f,  0.5f, 0.0f,
         0.5f, -0.5f, 0.0f,
        -0.5f, -0.5f, 0.0f,
        -0.5f,  0.5f, 0.0f,
    };

    float[] texCoords = new float[]
    {
         1.0f, 1.0f, // top right
         1.0f, 0.0f, // bottom right
         0.0f, 0.0f, // bottom left
         0.0f, 1.0f  // top left
    };

    // Generates Vertex Buffer Object and links it to vertices
    VertexBufferObject vbo = new VertexBufferObject(vertices, texCoords);
    // Generates Element Buffer Object and links it to indices
    ElementBufferObject ebo = new ElementBufferObject(indices);

    shader = new ShaderProgram();
    shader.Use();

    // Links VBO attributes such as coordinates and colors to VAO
    vao.LinkAttrib(vbo, shader.GetAttribLocation("aPos"), 3, VertexAttribPointerType.Float, 5 * sizeof(float), 0);
    vao.LinkAttrib(vbo, shader.GetAttribLocation("aTex"), 2, VertexAttribPointerType.Float, 5 * sizeof(float), 3 * sizeof(float));
    // Unbind all to prevent accidentally modifying them
    vao.Unbind();
    vbo.Unbind();
    ebo.Unbind();

    viewMatrix = Matrix4.CreateTranslation(0.0f, 0.0f, -3.0f);

    Vector2 widthHeight = GLContext.Instance.windowDimensions.Clone();
    widthHeight.x /= GLContext.Instance.ratios.x;
    widthHeight.y /= GLContext.Instance.ratios.y;
    orthoMatrix = Matrix4.CreateOrthographicOffCenter(0f, widthHeight.x, 0f, widthHeight.y, 0f, 1000f);

    Glfw.Time = 0.0D;
    do {
        if (vsync || Time.time - lastFrameTime > 1000 / targetFrameRate) {
            lastFrameTime = Time.time;

            frameCount++;
            if (Time.time - lastFPSTime > 1000) {
                lastFPS = (int)(frameCount / ((Time.time - lastFPSTime) / 1000.0f));
                lastFPSTime = Time.time;
                frameCount = 0;
            }

            UpdateMouseInput();
            game.Step();
            soundSystem.Step();

            ResetHitCounters();

            // TODO move to different thread
            Display();

            Time.newFrame();
            Glfw.PollEvents();
        }
    } while (!Glfw.WindowShouldClose(window));

    // the process is being terminated
    Close();
}

public override void Display() {
    // Specify the color of the background
    GL.ClearColor(0.07f, 0.13f, 0.17f, 1.0f);
    // Clean the back buffer and assign the new color to it
    GL.Clear(ClearBufferMask.ColorBufferBit);
    
    //game.Render();
    vao.Bind();
    tex.Bind();
    shader.Use();
    
    //Matrix4 model = transform.matrix;
    shader.SetInt("tex0", 0);
    shader.SetVector4("color", new Vector4(1f,1f,1f,1f)); // TODO change from Color32 to color clamp (0-1)
    //shader.SetMatrix4("model", model);
    shader.SetMatrix4("view", viewMatrix);
    shader.SetMatrix4("ortho", orthoMatrix);

    GL.DrawElements(PrimitiveType.Triangles, indices.Length, DrawElementsType.UnsignedInt, 0);

    tex.Unbind();
    vao.Unbind();

    Console.WriteLine(GL.GetError());
    
    // Swap the back buffer with the front buffer
    Glfw.SwapBuffers(window);
}

Texture2D.cs

using System.Collections;
using System.Drawing;
using System.Drawing.Imaging;
using OpenTK.Graphics.OpenGL4;

#pragma warning disable CA1416 // Validate platform compatibility
public unsafe class Texture2D : AssetInstance
{
    private static Texture2D? lastBound = null;

    internal Bitmap bitmap;
    private uint glTex = 0;
    private TextureUnit slot;

    public int width
    {
        get { return bitmap.Width; }

    }
    public int height
    {
        get { return bitmap.Height; }
    }

    private bool _wrap = false;
    public bool wrap
    {
        get { return _wrap; }
        set {
            // cannot change texture as it was not created
            if (glTex == 0) return;

            _wrap = value;

            // modify tex wrap
            Bind();
            GL.TexParameterI(TextureTarget.Texture2D, TextureParameterName.TextureWrapS,
                value ? new int[] { (int) All.Repeat } : new int[] { (int) All.ClampToEdge });
            GL.TexParameterI(TextureTarget.Texture2D, TextureParameterName.TextureWrapT,
                value ? new int[] { (int) All.Repeat } : new int[] { (int) All.ClampToEdge });
            Unbind();
        }
    }

    public Texture2D(int width, int height) : this(new Bitmap(width, height)) {
        if (width == 0 || height == 0) throw new ArgumentException("Width and Height need to be greater than 0!");
    }

    public Texture2D(Bitmap bitmap) {
        if (bitmap == null || bitmap.Width == 0 || bitmap.Height == 0) throw new ArgumentException("Width and Height need to be greater than 0!");

        this.bitmap = bitmap;
    }

    /// <summary>
    /// Creates a GL Texture
    /// </summary>
    internal void CreateTexture(TextureUnit slot = TextureUnit.Texture0) {
        if (glTex != 0)
            DestroyTexture();

        this.slot = slot;

        bitmap ??= new Bitmap(64, 64);

        fixed (uint* pointer = &glTex)
            GL.GenTextures(1, pointer);

        GL.ActiveTexture(slot);
        GL.BindTexture(TextureTarget.Texture2D, glTex);

        if (GameProcess.Main.settings.PixelArt) {
            GL.TexParameterI(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, new int[] { (int) TextureMinFilter.LinearMipmapNearest });
            GL.TexParameterI(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, new int[] { (int) TextureMagFilter.Nearest });
        }
        else {
            GL.TexParameterI(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, new int[] { (int) TextureMinFilter.LinearMipmapLinear });
            GL.TexParameterI(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, new int[] { (int) TextureMagFilter.Linear });
        }

        if (wrap) {
            GL.TexParameterI(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, new int[] { (int) TextureWrapMode.Repeat });
            GL.TexParameterI(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, new int[] { (int) TextureWrapMode.Repeat });
        }
        else {
            GL.TexParameterI(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, new int[] { (int) TextureWrapMode.ClampToEdge });
            GL.TexParameterI(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, new int[] { (int) TextureWrapMode.ClampToEdge });
        }

        UpdateTexture();

        Unbind();
    }

    /// <summary>
    /// Updates the GL Texture with glTexImage2D
    /// </summary>
    internal void UpdateTexture() {
        BitmapData data = bitmap.LockBits(new Rectangle(0, 0, width, height), ImageLockMode.ReadOnly, System.Drawing.Imaging.PixelFormat.Format32bppArgb);

        //GL.BindTexture(TextureTarget.Texture2D, glTex);
        GL.TexImage2D(TextureTarget.Texture2D, 0, PixelInternalFormat.Rgba, width, height, 0,
            OpenTK.Graphics.OpenGL4.PixelFormat.Bgra, PixelType.UnsignedByte, data.Scan0);
        GL.GenerateMipmap(GenerateMipmapTarget.Texture2D);

        bitmap.UnlockBits(data);
    }

    /// <summary>
    /// Destroys the GL Texture
    /// </summary>
    internal void DestroyTexture() {
        fixed (uint* pointer = &glTex)
            GL.DeleteTextures(1, pointer);

        glTex = 0;
    }

    internal void Bind() {
        if (lastBound == this) return;
        lastBound = this;
        GL.ActiveTexture(slot);
        GL.BindTexture(TextureTarget.Texture2D, glTex);
    }

    internal void Unbind()
    {
        if (lastBound != this) return;
        GL.BindTexture(TextureTarget.Texture2D, 0);
        lastBound = null;
    }

    internal override void Load() {
        CreateTexture();
    }

    internal override void Unload() {
        DestroyTexture();
    }
}
#pragma warning restore CA1416

VertexArrayObject.cs

using OpenTK.Graphics.OpenGL4;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;


// https://github.com/VictorGordan/opengl-tutorials/blob/main/YoutubeOpenGL%206%20-%20Textures/VAO.cpp
// translated from c++

/// <summary>
/// Vertex Array Object
/// </summary>
internal class VertexArrayObject
{
    private int[] id = new int[1];

    public VertexArrayObject() {
        GL.GenVertexArrays(1, id);
    }

    // Links a VBO Attribute such as a position or color to the VAO
    public void LinkAttrib(VertexBufferObject vbo, int layout, int numComponents, VertexAttribPointerType type, int stride, nint offset) {
        vbo.Bind();
        GL.VertexAttribPointer(layout, numComponents, type, false, stride, offset);
        GL.EnableVertexAttribArray(layout);
        vbo.Unbind();
    }

    // Binds the VAO
    public void Bind() {
        GL.BindVertexArray(id[0]);
    }

    // Unbinds the VAO
    public void Unbind() {
        GL.BindVertexArray(0);
    }

    // Deletes the VAO
    public void Delete() {
        GL.DeleteVertexArrays(1, id);
    }
}

VertexBufferObject.cs

using OpenTK.Graphics.OpenGL4;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

/// <summary>
/// Vertex Buffer Object
/// </summary>
internal class VertexBufferObject
{
    private int[] id = new int[1];

    // Constructor that generates a Vertex Buffer Object and links it to vertices
    public VertexBufferObject(float[] vertices, float[] texCoords) {
        int size = (vertices.Length + texCoords.Length) * sizeof(float); // not sure if it's required to specify sizeof(float)
        
        GL.GenBuffers(1, id);
        GL.BindBuffer(BufferTarget.ArrayBuffer, id[0]);
        GL.BufferData(BufferTarget.ArrayBuffer, size, IntPtr.Zero, BufferUsageHint.StaticDraw);
        GL.BufferSubData(BufferTarget.ArrayBuffer, 0, vertices.Length * sizeof(float), vertices);
        GL.BufferSubData(BufferTarget.ArrayBuffer, vertices.Length, texCoords.Length * sizeof(float), texCoords);
    }

    // Binds the VBO
    public void Bind() {
        GL.BindBuffer(BufferTarget.ArrayBuffer, id[0]);
    }

    // Unbinds the VBO
    public void Unbind() {
        GL.BindBuffer(BufferTarget.ArrayBuffer, 0);
    }

    // Deletes the VBO
    public void Delete() {
        GL.DeleteBuffers(1, id);
    }
}

ElementBufferObject.cs

using OpenTK.Graphics.OpenGL4;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

/// <summary>
/// ElementBufferObject
/// </summary>
internal class ElementBufferObject
{
    private int[] id = new int[1];

    // Constructor that generates a Elements Buffer Object and links it to indices
    public ElementBufferObject(uint[] indices) {
        int size = indices.Length * sizeof(uint);

        GL.GenBuffers(1, id);
        GL.BindBuffer(BufferTarget.ElementArrayBuffer, id[0]);
        GL.BufferData(BufferTarget.ElementArrayBuffer, size, indices, BufferUsageHint.StaticDraw);
    }

    // Binds the EBO
    public void Bind() {
        GL.BindBuffer(BufferTarget.ElementArrayBuffer, id[0]);
    }

    // Unbinds the EBO
    public void Unbind() {
        GL.BindBuffer(BufferTarget.ElementArrayBuffer, 0);
    }

    // Deletes the EBO
    public void Delete() {
        GL.DeleteBuffers(1, id);
    }
}

ShaderProgram.cs (taken and adapted from: [https://github.com/opentk/LearnOpenTK/blob/master/Common/Shader.cs])

using OpenTK.Graphics.OpenGL4;
using OpenTK.Mathematics;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;

// https://github.com/opentk/LearnOpenTK/blob/master/Common/Shader.cs
public class ShaderProgram
{
    public static readonly string[] defaultShaders = new string[] {
        "Namespace.To.Shader.Folder.defaultVertex.shader", // these exist in the assembly
        "Namespace.To.Shader.Folder.defaultFrag.shader",
    };

    public static string GetEmbeddedResource(string filename)
    {
        var assembly = Assembly.GetExecutingAssembly();

        using (Stream stream = assembly.GetManifestResourceStream(filename))
        using (StreamReader reader = new StreamReader(stream))
        {
            string result = reader.ReadToEnd();
            return result;
        }
    }

    public readonly int Handle;

    private readonly Dictionary<string, int> _uniformLocations;

    public ShaderProgram() : this(defaultShaders[0], defaultShaders[1], true) { }

    // This is how you create a simple shader.
    // Shaders are written in GLSL, which is a language very similar to C in its semantics.
    // The GLSL source is compiled *at runtime*, so it can optimize itself for the graphics card it's currently being used on.
    // A commented example of GLSL can be found in shader.vert.
    public ShaderProgram(string vertPath, string fragPath, bool inAssembly = true)
    {
        // There are several different types of shaders, but the only two you need for basic rendering are the vertex and fragment shaders.
        // The vertex shader is responsible for moving around vertices, and uploading that data to the fragment shader.
        //   The vertex shader won't be too important here, but they'll be more important later.
        // The fragment shader is responsible for then converting the vertices to "fragments", which represent all the data OpenGL needs to draw a pixel.
        //   The fragment shader is what we'll be using the most here.

        // Load vertex shader and compile
        string shaderSource;
        
        if (inAssembly)
            shaderSource = GetEmbeddedResource(vertPath);
        else
            shaderSource = File.ReadAllText(vertPath);

        // GL.CreateShader will create an empty shader (obviously). The ShaderType enum denotes which type of shader will be created.
        var vertexShader = GL.CreateShader(ShaderType.VertexShader);

        // Now, bind the GLSL source code
        GL.ShaderSource(vertexShader, shaderSource);

        // And then compile
        CompileShader(vertexShader);

        // We do the same for the fragment shader.
        if (inAssembly)
            shaderSource = GetEmbeddedResource(fragPath);
        else
            shaderSource = File.ReadAllText(fragPath);

        var fragmentShader = GL.CreateShader(ShaderType.FragmentShader);
        GL.ShaderSource(fragmentShader, shaderSource);
        CompileShader(fragmentShader);

        // These two shaders must then be merged into a shader program, which can then be used by OpenGL.
        // To do this, create a program...
        Handle = GL.CreateProgram();

        // Attach both shaders...
        GL.AttachShader(Handle, vertexShader);
        GL.AttachShader(Handle, fragmentShader);

        // And then link them together.
        LinkProgram(Handle);

        // When the shader program is linked, it no longer needs the individual shaders attached to it; the compiled code is copied into the shader program.
        // Detach them, and then delete them.
        GL.DetachShader(Handle, vertexShader);
        GL.DetachShader(Handle, fragmentShader);
        GL.DeleteShader(fragmentShader);
        GL.DeleteShader(vertexShader);

        // The shader is now ready to go, but first, we're going to cache all the shader uniform locations.
        // Querying this from the shader is very slow, so we do it once on initialization and reuse those values
        // later.

        // First, we have to get the number of active uniforms in the shader.
        GL.GetProgram(Handle, GetProgramParameterName.ActiveUniforms, out var numberOfUniforms);

        // Next, allocate the dictionary to hold the locations.
        _uniformLocations = new Dictionary<string, int>();

        // Loop over all the uniforms,
        for (var i = 0; i < numberOfUniforms; i++)
        {
            // get the name of this uniform,
            var key = GL.GetActiveUniform(Handle, i, out _, out _);

            // get the location,
            var location = GL.GetUniformLocation(Handle, key);

            // and then add it to the dictionary.
            _uniformLocations.Add(key, location);
        }
    }

    private static void CompileShader(int shader)
    {
        // Try to compile the shader
        GL.CompileShader(shader);

        // Check for compilation errors
        GL.GetShader(shader, ShaderParameter.CompileStatus, out var code);
        if (code != (int)All.True)
        {
            // We can use `GL.GetShaderInfoLog(shader)` to get information about the error.
            var infoLog = GL.GetShaderInfoLog(shader);
            throw new Exception($"Error occurred whilst compiling Shader({shader}).\n\n{infoLog}");
        }
    }

    private static void LinkProgram(int program)
    {
        // We link the program
        GL.LinkProgram(program);

        // Check for linking errors
        GL.GetProgram(program, GetProgramParameterName.LinkStatus, out var code);
        if (code != (int)All.True)
        {
            // We can use `GL.GetProgramInfoLog(program)` to get information about the error.
            throw new Exception($"Error occurred whilst linking Program({program})");
        }
    }

    // A wrapper function that enables the shader program.
    public void Use()
    {
        GL.UseProgram(Handle);
    }

    // The shader sources provided with this project use hardcoded layout(location)-s. If you want to do it dynamically,
    // you can omit the layout(location=X) lines in the vertex shader, and use this in VertexAttribPointer instead of the hardcoded values.
    public int GetAttribLocation(string attribName)
    {
        return GL.GetAttribLocation(Handle, attribName);
    }

    // Uniform setters
    // Uniforms are variables that can be set by user code, instead of reading them from the VBO.
    // You use VBOs for vertex-related data, and uniforms for almost everything else.

    // Setting a uniform is almost always the exact same, so I'll explain it here once, instead of in every method:
    //     1. Bind the program you want to set the uniform on
    //     2. Get a handle to the location of the uniform with GL.GetUniformLocation.
    //     3. Use the appropriate GL.Uniform* function to set the uniform.

    /// <summary>
    /// Set a uniform int on this shader.
    /// </summary>
    /// <param name="name">The name of the uniform</param>
    /// <param name="data">The data to set</param>
    public void SetInt(string name, int data)
    {
        GL.UseProgram(Handle);
        GL.Uniform1(_uniformLocations[name], data);
    }

    /// <summary>
    /// Set a uniform float on this shader.
    /// </summary>
    /// <param name="name">The name of the uniform</param>
    /// <param name="data">The data to set</param>
    public void SetFloat(string name, float data)
    {
        GL.UseProgram(Handle);
        GL.Uniform1(_uniformLocations[name], data);
    }

    /// <summary>
    /// Set a uniform Matrix4 on this shader
    /// </summary>
    /// <param name="name">The name of the uniform</param>
    /// <param name="data">The data to set</param>
    /// <remarks>
    ///   <para>
    ///   The matrix is transposed before being sent to the shader.
    ///   </para>
    /// </remarks>
    public void SetMatrix4(string name, Matrix4 data)
    {
        GL.UseProgram(Handle);
        GL.UniformMatrix4(_uniformLocations[name], true, ref data);
    }

    /// <summary>
    /// Set a uniform Vector3 on this shader.
    /// </summary>
    /// <param name="name">The name of the uniform</param>
    /// <param name="data">The data to set</param>
    public void SetVector3(string name, Vector3 data)
    {
        GL.UseProgram(Handle);
        GL.Uniform3(_uniformLocations[name], data);
    }

    /// <summary>
    /// Set a uniform Vector4 on this shader.
    /// </summary>
    /// <param name="name">The name of the uniform</param>
    /// <param name="data">The data to set</param>
    public void SetVector4(string name, Vector4 data)
    {
        GL.UseProgram(Handle);
        GL.Uniform4(_uniformLocations[name], data);
    }
}

And my vertex and frag shaders are:

defaultVertex.shader

#version 330 core

// Positions/Coordinates
layout (location = 0) in vec3 aPos;
// Texture Coordinates
layout (location = 1) in vec2 aTex;


// Outputs the texture coordinates to the fragment shader
out vec2 texCoord;

uniform mat4 model; // disabled for debug purposes
uniform mat4 view;
uniform mat4 ortho;


void main()
{
    // Outputs the positions/coordinates of all vertices
    gl_Position = vec4(aPos, 1.0) /* model */* view * ortho;
    // Assigns the texture coordinates from the Vertex Data to "texCoord"
    texCoord = aTex;
}

defaultFrag.shader

#version 330 core

// Outputs colors in RGBA
out vec4 FragColor;


// Inputs the texture coordinates from the Vertex Shader
in vec2 texCoord;

// Gets the color
uniform vec4 color;
// Gets the Texture Unit from the main function
uniform sampler2D tex0;


void main()
{
    FragColor = texture(tex0, texCoord) * color;
}

I have learned the basics and moderately understood how open gl works and how I'm supposed to use it in the span of the last 3 days, so I'm really underqualified to fix whatever issue this is, I also apologize if it's a stupidly simple mistake I made and for the quantity of code and its lack of tidiness.

I'd also like to add that I'm forcing OpenGL 4 in GLFW.NET

Glfw.WindowHint(Hint.ContextVersionMajor, 4);
Glfw.WindowHint(Hint.ContextVersionMinor, 4);

and the window I'm using is 800x800, with the current output of just a clear background colour: GL Window Test

And these are the triangles I managed to render using a c++ tutorial: Triangles test

What I have tried:
Disabling the color multiplication in the Fragment shader
Disabling all the matrices that were multiplied on top of the position
Using different vertices and/or indices
Setting the out FragColor to a flat value


Solution

  • It has been a few days and a lot of headaches, but I got it to work. There were 2 main issues that caused nothing to render: Using GL.Enable(EnableCap.DepthTest) seemed to cause everything not to render. Using sub buffers with VertexAttribPointers would not have worked since the sub buffers placed the tex coords after the vertices and VertexAttribPointer seemed to take the vertices along with the tex coords per vertex and not all vertices, then all tex coords. (I'm not certain that this is the case, but putting the vertices and tex coords on the same "line" (vertex) worked for me).

    There were probably other problems with the order of execution of GL functions, but I honestly cannot remember anymore... I'll mark this as solved with this answer so it is no longer opened, and I'll leave this code here in case someone (highly unlikely) will ever need it.

        private readonly float[] _verticesComplete =
            {
                // Position         Texture coordinates
                 0.5f,  0.5f, 0.0f, 1.0f, 1.0f, // top right
                 0.5f, -0.5f, 0.0f, 1.0f, 0.0f, // bottom right
                -0.5f, -0.5f, 0.0f, 0.0f, 0.0f, // bottom left
                -0.5f,  0.5f, 0.0f, 0.0f, 1.0f  // top left
            };
    
        private float[][] _vertices =
        {
            // Position         Texture coordinates
             new float[] {  0.5f,  0.5f, 0.0f },
             new float[] {  0.5f, -0.5f, 0.0f },
             new float[] { -0.5f, -0.5f, 0.0f },
             new float[] { -0.5f,  0.5f, 0.0f }
        };
    
        private readonly float[][] texCoords =
        {
            new float[] { 1.0f, 1.0f }, // top right
            new float[] { 1.0f, 0.0f }, // bottom right
            new float[] { 0.0f, 0.0f },// bottom left
            new float[] { 0.0f, 1.0f } // top left
        };
    
        private readonly uint[] _indices =
        {
            0, 1, 3,
            1, 2, 3
        };
    
        private int _elementBufferObject;
    
        private int _vertexBufferObject;
    
        private int _vertexArrayObject;
    
        private ShaderProgram _shader;
    
        private Texture _texture;
    
        // We create a double to hold how long has passed since the program was opened.
        private double _time;
    
        // Then, we create two matrices to hold our view and projection. They're initialized at the bottom of OnLoad.
        // The view matrix is what you might consider the "camera". It represents the current viewport in the window.
        private Matrix4 _view;
    
        // This represents how the vertices will be projected. It's hard to explain through comments,
        // so check out the web version for a good demonstration of what this does.
        private Matrix4 _projection;
    
        public override void Run() {
            // perform gl setup
            //GL.Enable(EnableCap.DepthTest);
            GL.Enable(EnableCap.Multisample);
            GL.Enable(EnableCap.Blend);
            GL.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
            
            // TEST
    
            _vertexArrayObject = GL.GenVertexArray();
            GL.BindVertexArray(_vertexArrayObject);
    
    
            _texture = Texture.LoadFromFile("Assets/Sprites/Test.png");
    
            _vertices = _texture.GetVertices();
    
            var vertices = new List<float>();
    
            for (int i = 0; i < _vertices.Length; i++) {
                float[] vertex = new float[5];
                Array.Copy(_vertices[i], vertex, _vertices[i].Length);
                Array.Copy(texCoords[i], 0, vertex, _vertices[i].Length, texCoords[i].Length);
                vertices.AddRange(vertex);
    
                Console.WriteLine(_vertices[i][0] + ", " + _vertices[i][1] + ", " + _vertices[i][2]);
                Console.WriteLine(texCoords[i][0] + ", " + texCoords[i][1]);
                Console.WriteLine();
            }
    
            _vertexBufferObject = GL.GenBuffer();
            GL.BindBuffer(BufferTarget.ArrayBuffer, _vertexBufferObject);
            GL.BufferData(BufferTarget.ArrayBuffer, vertices.Count * sizeof(float), vertices.ToArray(), BufferUsageHint.StaticDraw);
    
            _elementBufferObject = GL.GenBuffer();
            GL.BindBuffer(BufferTarget.ElementArrayBuffer, _elementBufferObject);
            GL.BufferData(BufferTarget.ElementArrayBuffer, _indices.Length * sizeof(uint), _indices, BufferUsageHint.StaticDraw);
    
            // shader.vert has been modified. Take a look at it after the explanation in OnRenderFrame.
            _shader = new ShaderProgram("UGXP.Core.Render.Shaders.shader.vert", "UGXP.Core.Render.Shaders.shader.frag");
            _shader.Use();
    
            var vertexLocation = _shader.GetAttribLocation("aPosition");
            GL.EnableVertexAttribArray(vertexLocation);
            GL.VertexAttribPointer(vertexLocation, 3, VertexAttribPointerType.Float, false, 5 * sizeof(float), 0);
    
            var texCoordLocation = _shader.GetAttribLocation("aTexCoord");
            GL.EnableVertexAttribArray(texCoordLocation);
            GL.VertexAttribPointer(texCoordLocation, 2, VertexAttribPointerType.Float, false, 5 * sizeof(float), 3 * sizeof(float));
    
            _texture.Use(TextureUnit.Texture0);
    
            _shader.SetInt("slot", 0);
    
            // For the view, we don't do too much here. Next tutorial will be all about a Camera class that will make it much easier to manipulate the view.
            // For now, we move it backwards three units on the Z axis.
            _view = Matrix4.CreateTranslation(0.0f, 0.0f, 0.0f);
    
            Vector2 widthHeight = Instance.windowSize.Clone();
    
            float orthoSize = 7f;
            float aspect = widthHeight.x / widthHeight.y;
    
            float heightUnits = orthoSize;
            float widthUnits = heightUnits * aspect;
    
            // after all the research
            // ortho size means the half of the total amount of GAME UNITS attributed to the height of the projection.
            // the width is calculated with the height times the aspect ratio
            // the height / width units are then placed as left right, bottom, top sizes of the projection, which esentially doubles the size.
    
            Console.WriteLine(widthUnits + "x" + heightUnits);
    
            
            _projection = Matrix4.CreateOrthographicOffCenter(-widthUnits, widthUnits, -heightUnits, heightUnits,  -1.0f, 1000.0f);
    
            //_projection = Matrix4.CreatePerspectiveFieldOfView(MathHelper.DegreesToRadians(45f), Instance.windowDimensions.x/ (float) Instance.windowDimensions.y, 0.1f, 100.0f);
    
            // Now, head over to OnRenderFrame to see how we setup the model matrix.
    
            // ENDTEST
    
    
            Glfw.Time = 0.0D;
            do {
                if (vsync || Time.time - lastFrameTime > 1000 / targetFrameRate) {
                    lastFrameTime = Time.time;
    
                    frameCount++;
                    if (Time.time - lastFPSTime > 1000) {
                        lastFPS = (int)(frameCount / ((Time.time - lastFPSTime) / 1000.0f));
                        lastFPSTime = Time.time;
                        frameCount = 0;
                    }
    
                    UpdateMouseInput();
                    game.Step();
                    soundSystem.Step();
    
                    ResetHitCounters();
    
                    Display();
    
                    Time.newFrame();
                    Glfw.PollEvents();
                }
            } while (!Glfw.WindowShouldClose(window));
    
            // the process is being terminated
            Close();
        }
    
        public override void Display() {
            // We add the time elapsed since last frame, times 4.0 to speed up animation, to the total amount of time passed.
            _time += Time.deltaTime;
    
            // We clear the depth buffer in addition to the color buffer.
            GL.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
    
            GL.BindVertexArray(_vertexArrayObject);
    
            _texture.Use(TextureUnit.Texture0);
            _shader.Use();
    
            // Finally, we have the model matrix. This determines the position of the model.
            var model = Matrix4.Identity * Matrix4.CreateRotationX((float)MathHelper.DegreesToRadians(0f));
    
            // Then, we pass all of these matrices to the vertex shader.
            // You could also multiply them here and then pass, which is faster, but having the separate matrices available is used for some advanced effects.
    
            // IMPORTANT: OpenTK's matrix types are transposed from what OpenGL would expect - rows and columns are reversed.
            // They are then transposed properly when passed to the shader. 
            // This means that we retain the same multiplication order in both OpenTK c# code and GLSL shader code.
            // If you pass the individual matrices to the shader and multiply there, you have to do in the order "model * view * projection".
            // You can think like this: first apply the modelToWorld (aka model) matrix, then apply the worldToView (aka view) matrix, 
            // and finally apply the viewToProjectedSpace (aka projection) matrix.
            _shader.SetMatrix4("model", model);
            _shader.SetVector4("color", new Vector4(1f,1f,1f,1f) /*new Vector4(.5f,.2f,.12f,1f)*/);
            _shader.SetMatrix4("view", Matrix4.LookAt(new Vector3(0,0,0), new Vector3(0,0,0) -Vector3.UnitZ, Vector3.UnitY));
            _shader.SetMatrix4("projection", _projection);
    
            GL.DrawElements(PrimitiveType.Triangles, _indices.Length, DrawElementsType.UnsignedInt, 0);
            
            // Swap the back buffer with the front buffer
            Glfw.SwapBuffers(window);
        }
    

    The Texture class I, unfortunately deleted, and the now-in-use Texture2D class is much more complex and beyond this answer's purpose, it is, in essence, very similar to the texture class I added in the question. The Texture class contains a GetVertices method that returns a multi-dimensional float array of vertices (I made it so I can visualize them better and more easily add the texture coordinates to the vertex, which finally were turned into a single-dimension float array).

    This is, at its core, the code I'm using right now, however it's spread across multiple classes and methods etc. as well as modified to accommodate for other purposes.

    Let me know if this is not the right way to mark a thread as solved or if there's anything wrong with doing this or the code. I apologize in advance as I do not know "how to stack overflow". (I barely ever ask or answer questions here...)