DEV Community

Cover image for [11/52] OpenGL is Still Decent, Actually
Brian Kirkpatrick
Brian Kirkpatrick

Posted on • Originally published at github.com

[11/52] OpenGL is Still Decent, Actually

I know... I promised I'd be doing more engineering. We'll get there! But I was consuming some interesting digital creator / software engineering content the other day. There was one guy in particular (I won't say exactly who) who tends to do pretty interesting stuff, but who can be a little bit iconoclastic. He went on a weird rant about how obscure, obtuse, and inaccessible OpenGL is. It was very strange because the critique was based on how difficult it was to get sprites or pixels up on the screen. But compared to pretty much anything else--Vulkan, Carbon, XTerminal--it's pretty straightforward and well-established how to get up and going.

This guy is pretty experienced, and he knows what he's doing, so it seemed strange and the comment came out of nowhere. So, naturally, it got me going and thinking, "okay, what is the simplest and shortest path to get up and going with OpenGL?" Assume you're trying to just put up a simple animation, create a simple game, put some sprites on the screen, etc. What is the shortest path to do that? And as it turns out, unless you're doing something platform-specific like Windows GDI, OpenGL is still a really good way to go.

There's a few other things you need in combination with it. SDL is an absolutely fantastic library--strongly recommended, check it out if you aren't familiar with it. There's a lot more to it, but for getting out of the box and going with a window and an event loop and a GL context and all that, it's fantastic. And of course GLEW is practically required for a lot of things. So today we're going to walk through, really quick, a brief demonstration of what the "shortest path" to a working "sprites on screen" is.

Let's Get Started

Begin with a blank C++ project. We'll create the following files as placeholders for future content:

  • .gitignore

  • basic.f.glsl

  • basic.v.glsl

  • CMakeLists.txt

  • main.cpp

After initializing our git repository, we'll also want to add some dependencies. We'll use git submodules to do this, and in most cases these will need to come from specific branches. So, run git submodule add for the following:

You'll notice we're also adding SDL Image here, which is a fantastic extension to SDL that gives you out-of-the-box support for loading surfaces from a wide variety of image formats. We're also using a specific fork of GLEW that supports inclusion via CMake, to automate the dependency inclusion within our CMake project definition. Once those dependencies are cloned and submodules initialized (recursively!), we're ready to start populating our files.

You'll also notice we have some shaders. If you haven't messed with GLSL before, it's fascinating! We'll probably do another talk specifically about radiometry and applications to graphics programming, thermal, electro-optics, and other fields. We'll also want a test texture; you can use any .PNG you want, but I went with a nice picture of a seagull. Cheers.

Our goal--our mission, if we choose to accept it--is to put this image up in the window. If we do this well, it should be clear how we can extend this in the future to do more sophisticated sprite models and behaviors within the context of an app or game engine.

The Main Thing

Let's start in main.cpp with some dependencies. We'll include the following, roughly broken into system includes and dependency includes:

#include <fstream>
#include <iostream>
#include <sstream>
#include <vector>
#include <SDL.h>
#include <GL/glew.h>
#include <SDL_image.h>
Enter fullscreen mode Exit fullscreen mode

Vertex Formats

Next, we'll think about our data model. Let's stay away with classes and focus on how the state of our application will be packed into an aggregation of bytes (a struct).

SDL_Surface* logo_rgba;
const GLfloat verts[4][4] = {
    { -1.0f, -1.0f, 0.0f, 1.0f },
    { -1.0f, 1.0f, 0.0f, 0.0f },
    { 1.0f, 1.0f, 1.0f, 0.0f },
    { 1.0f, -1.0f, 1.0f, 1.0f }
};
const GLint indices[6] = {
    0, 1, 2, 0, 2, 3
};
Enter fullscreen mode Exit fullscreen mode

You do need to think about your vertex format! Briefly, this means thinking about what information is attached to, or defines, each vertex in the drawing sequence you will call. Since we're focusing on a textured 2d sprite, our verts array defines a set of 4 vertices, each of which defines 4 values:

  • An x (position) coordinate
  • An y (position) coordinate
  • A u (texture) coordinate
  • A v (texture) coordinate

We'll see how we "encode", or tell OpenGL about, the format of this vertex sequence in subsequent calls. And since we only want to define each vertex once, we also have an index buffer to define how the vertices are combined to form a shape (in this case, two triangles).

Application State

We also need to think about what information defines the state our our application model. Let's use the following, which includes SDL references and a healthy mix of OpenGL unsigned integers (effectively used as handles to GPU data).

struct App {
    SDL_Window* m_window = NULL;
    SDL_GLContext m_context = 0;
    GLuint m_vao = 0;
    GLuint m_vbo = 0;
    GLuint m_ebo = 0;
    GLuint m_tex = 0;
    GLuint m_vet_shader = 0;
    GLuint m_frag_shader = 0;
    GLuint m_shader_prog = 0;
};
Enter fullscreen mode Exit fullscreen mode

Behaviors

We want to define procedures by which we initialize and free specific models within this application. Let's define prototypes for the following:

void initApplication(App* app);
void freeApplication(App* app);
void initShaders(App* app);
void initGeometries(App* app);
void initMaterials(App* app);
Enter fullscreen mode Exit fullscreen mode

We'll also want some helper methods and a function to define specific loops. (In the long run, we'd want to split these loops across threads for different cadences like rendering, I/O handling, and internal updates.)

const char* getSource(const char* path);
void renderLoop(App* app);
Enter fullscreen mode Exit fullscreen mode

And now we have enough defined to think about how we use these behaviors in the context of a program. So let's write our main() entry point!

The Main Main

First, let's start up the application by allocating, loading resources, and calling our initializers.

int main(int nArgs, char** vArgs) {
    // startup 
    std::cout << "Initialzing..." << std::endl;
    std::string filename = "logo.png";
    logo_rgba = IMG_Load(filename.c_str());
    App* app = new App();
    initApplication(app);
    initShaders(app);
    initGeometries(app);
    initMaterials(app);0

    // ...
}
Enter fullscreen mode Exit fullscreen mode

Even though we've consolidated all of our state within a specific structure, you'll notice we've broken out initialization into specific steps. If you've used THREE.js before, this model may loop familiar. In the long run, this will make it easy to extract and organize specific models within our application--like individual shader programs, complex geometry data that may be reused or even animated, and material resources that need internally-organized bindings to things like multiple texture uniforms.

(We might look at a "part two" in which we see how these models can evolve into something more... interesting, if not entirely professional yet.0)

Next we can think about our "core" loop. This is pretty straightforward:

int main(int nArgs, char** vArgs) {
    // ...

    // main loop
    std::cout << "Running" << std::endl;
    bool is_running = true;
    while (is_running) {
        SDL_Event event;
        while (SDL_PollEvent(&event)) {
            if (event.type == SDL_WINDOWEVENT && event.window.event == SDL_WINDOWEVENT_CLOSE) {
                is_running = false;
                break;
            }
        }
        renderLoop(app);
    }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Finally, we clear up our resources:

int main(int nArgs, char** vArgs) {
    // ...

    // cleanup
    std::cout << "Exiting..." << std::endl;
    freeApplication(app);
    delete app;
    SDL_FreeSurface(logo_rgba);
    logo_regba =nNULL;
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

Initialization

When we initialize the application, what are we talking about? Since we have separate initialization for our different groups of GL data, this is largely SDL-specific. Let's write our initApplication() to handle this top-level logic.

void initApplication(App* app) {
    if (SDL_init(SDL_INIT_VIDEO) < 0) {
        std::cerr << "Initializing SDL video failed!" << std::endl;
        throw std::exception();
    }

    // create window
    app->m_window = SDL_CreateWindow("App", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 800, 600, SDL_WINDOW_OPENGL);
    if (app->m_window == NULL) {
        std::cerr << "Creating main window failed!" << std::endl;
        SDL_Quit();
        throw std::exception();
    }

    // initialize GL context
    SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
    SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 1);
    SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE);
    SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
    app->m_context = SDL_GL_CreateContext(app->m_window);
    if (app->m_context== NULL) {
        std::cerr << "Creating GL context failed!" << std::endl;
        SDL_DestroyWindow(app->m_window);
        SDL_Quit();
        throw std::exception();
    }

    // initialize glew
    GLenum err = glewInit();
    if (err != GLEW_OK) {
        std::cerr << "Initializing GLEW failed!" << std::endl;
        SDL_GL_DeleteContext(app->m_context);
        SDL_DestroyWindow(app->m_window);
        SDL_Quit();
        throw std::exception();
    }
}
Enter fullscreen mode Exit fullscreen mode

The big "lift" here is window management, of course, and that's the important part SDL automates for us. Once we have an agnostic window generated, getting a GL context is straightforward. These things would be 80% of the effort (a nightmare) if we didn't have SDL or something like it. Once you have your GL context, you're home free and almost everything else is platform-neutral.

A Brief Break for CMake

Let's jump over to our CMakeLists.txt for a moment to make sure we'll be able to build this mess once we've finished coding. We'll start with the standard three CMake commands: defining the version, defining the project, and defining the main build artifact (executable, in this case).

cmake_minimum_required(VERSION 3.14)
project(11-of-52--opengl-is-still-decent-actually)
add_executable(${PROJECT_NAME}
    "main.cpp"
)
Enter fullscreen mode Exit fullscreen mode

Next, we'll assert specific options for our dependencies.

# assert dependency options
set(SDL2IMAGE_VENDORED OFF)
Enter fullscreen mode Exit fullscreen mode

Now we can recursively include our submodules:

# ensure dependencies are built
add_subdirectory("glew-cmake/")
add_subdirectory("SDL/")
add_subdirectory("SDL_image/)
Enter fullscreen mode Exit fullscreen mode

Now we'll want to make sure our main build target can resolve the appropriate #include paths.

target_link_libraries(${PROJECT_NAME} PRIVATE
    SDL2::SDL2
    SDL2::SDL2main
    OpenGL32
    libglew_static
    SDL2_image
)
Enter fullscreen mode Exit fullscreen mode

When in doubt, these are basically the library names. Some CMake projects will have their own unique library names defined (the :: is a big clue); you can always check their CMakeLists.txt for an add_library() directive. There's also some useful logic/automation build into the find_package() directive within CMake--that might be worth going over in its own video at some point.

Finally, we'll want to set specific runtime resources to copy into the binary folder. We'll do this for static resources (like our image), as well as dynamic resources (like dependency DLLs). At some point, you can automate a degree of this with something like CPack, which is also probably worth its own video.

# define static runtime resources
set(OUTPUT_PATH "${CMAKE_BINARY_DIR}/Debug")
file(MAKE_DIRECTORY ${OUTPUT_PATH})
configure_file("basic.f.glsl" "${OUTPUT_PATH}/basic.f.glsl" COPYONLY)
configure_file("basic.v.glsl" "${OUTPUT_PATH}/basic.v.glsl" COPYONLY)
configure_file("logo.png" "${OUTPUT_PATH}/logo.png" COPYONLY)

# define dynamic runtime resources
add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
    COMMAND ${CMAKE_COMMAND} -E copy
        ${CMAKE_BINARY_DIR}/SDL/Debug/SDL2d.dll
        $<TARGET_FILE_DIR:${PROJECT_NAME}>/SDL2d.dll
    COMMAND ${CMAKE_COMMAND} -E copy
        ${CMAKE_BINARY_DIR}/SDL_image/Debug/SDL2_imaged.dll
        $<TARGET_FILE_DIR:${PROJECT_NAME}>/SDL2_imaged.dll
)
Enter fullscreen mode Exit fullscreen mode

(We're cheating a little bit here, because we know what DLLs will be generated and where they need to go.)

And that just about does it for our CMake. This is enough for us to do a basic configure test from the command line:

cmake -S . -B build
Enter fullscreen mode Exit fullscreen mode

Back to the Source

Let's finish our initialization. We've initialized the application. How are we going to initialize our shaders? There's a basic three-step process: first, we compile the vertex shader from source; second, we compile the fragment shader from source; third, we link these two shaders into a fully-defined graphics program.

void initShaders(App* app) {
    GLint status;
    char err_buf[512];
    glGenVertexArays(1, &(app->m_vao));
    glBindVertexArray(app->m_vao);

    // compile vertex shader
    app->m_vert_shader = glCreateShader(GL_VERTEX_SHADER);
    const char* vertexSource = getSource("basic.v.glsl");
    glShaderSource(app->m_vert_shader, 1, &vertexSource, NULL);
    glCompileShader(app->m_vert_shader);
    glGetShaderiv(app->m_vert_shader, GL_COMPILE_STATUS, &status);
    if (status != GL_TRUE) {
        glGetShaderInfoLog(app->m_vert_shader, sizeof(err_buf), NULL, err_buf);
        err_buf[sizeof(err_buf)-1] = '\0';
        std::cerr << "Compiling vertex shader failed!" << std::endl;
        std::cerr << err_buf << std::endl;
        return;
    }

    // compile fragment shader
    app->m_frag_shader = glCreateShader(GL_FRAGMENT_SHADER);
    const char* fragmentSource = getSource("basic.f.glsl");
    glShaderSource(app->m_frag_shader, 1, &fragmentSource, NULL);
    glCompileShader(app->m_frag_shader);
    glGetShaderiv(app->m_frag_shader, GL_COMPILE_STATUS, &status);
    if (status != GL_TRUE) {
        glGetShaderInfoLog(app->m_frag_shader, sizeof(err_buf), NULL, err_buf);
        err_buf[sizeof(err_buf)-1] = '\0';
        std::cerr << "Compiling fragment shader failed!" << std::endl;
        std::cerr << err_buf << std::endl;
        return;
    }

    // link shader program
    app->m_shader_prog = glCreateProgram();
    glAttachShader(app->m_shader_prog, app->m_vert_shader);
    glAttachShader(app->m_shader_prog, app->m_frag_shader);
    glBindFragDataLocation(app->m_shader_prog, 0, "uRGBA");
    glLinkProgram(app->m_shader_prog);
    glUseProgram(app->m_shader_prog);
    return;
}
Enter fullscreen mode Exit fullscreen mode

(You'll notice we're null-terminating our string copy from the error buffer, which isn't a great idea in general. Don't try this at home, kids!)

In modern graphics programming, you would not be necessarily doing this full build from source at runtime like this. Instead, you'd have an intermediate format (like SPIR-V, with Vulkan) that you would use to do a lot of the preliminary compilation. For our purposes, though, this is enough (and interesting, and useful; it also gives a transparent view into our application state and graphics pipeline.)

Note that we "know" special things about our shader program, in this case. For example, we "know" that there is a uniform variable we'll need to bind to our texture data. We'll look at how we set this up in the material initialization.

Geometries

Now let's think about our geometry data. We've defined a set of vertices with a specific format, and some indices that define how those are mapped to specific shapes for drawing. We need to tell OpenGL how these vertices are structured. We also need to hand off (copy) the data buffers themselves. These are mostly done with buffer commands, using the "handles" (unsigned integers) we've defined as part of our application state to share persistent references.

void initGeometries(App* app) {
    // populate vertex and element buffers
    glGenBuffers(1, &app->m_vbo);
    glBindBuffer(GL_ARRAY_BUFFER, app->m_vbo);
    glBufferData(GL_ARRAY_BUFFER, sizeof(verts), verts, GL_STATIC_DRAW);
    glGenBuffers(1, &app->m_ebo);
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, app->m_ebo);
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

    // bind vertex position and texture coordinate attributes
    GLint pos_attr_loc = glGetAttribLocation(app->m_shader_prog, "aXY");
    glVertexAttribPointer(pos_attr_loc, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(GLfloat), (void*)0);
    glEnableVertexAttribArray(pos_attr_loc);
    GLint tex_attr_loc = glGetAttribLocation(app->m_shader_prog, "aUV");
    glVertexAttribPointer(tex_attr_loc, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(GLfloat), (void*)(2 * sizeof(Glfloat)));
    glEnableVertexAttribArray(tex_attr_loc);
}
Enter fullscreen mode Exit fullscreen mode

That middle clause is probably the most interesting, because this is where we tell OpenGL how the vertex attributes are structured. Given a sequence of vertex attributes, each segment defines a vertex--but how is that information "packed"? There are a total of four values (or "stride") between each segment (that is, the segment length).

  • The first pair of values define the "x-y" pair, or vec2, vertex attribute; these are floats and offset from the beginning of the segment by zero values

  • The second pair of values define the "u-v" pair, or vec, vertex attribute; these are floats and offset from the beginning of the segment by two values

Materials

With our geometry data and shader program defined, we need to pass in material data. In this case, we have a single diffuse texture that will be sampled to define the pixel (or fragment) color within our "sprite". We do this by loading the image data from an SDL surface for OpenGL to reference as a "uniform" input to our shader program.

void initMaterials(App* app) {
    // results in the successful transcription of raw image bytes into a uniform texture buffer
    glGenTextures(1, &app->m_tex);
    glActiveTexture(GL_TEXUTRE0);
    glBindTexture(GL_TEXTURE_2D, app->m_tex);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 256, 256, 0, GL_RGB, GL_UNSIGNED_BYTE, NULL);
    glUniform1i(glGetUniformLocation(app->m_shader_prog, "uTexture"), 0);
    glEnable(GL_BLEND);
    glBendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

    // define texture sampling parameters and map raw image data
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 256, 256, GL_RGBA, GL_UNSIGNED_INT_8_8_8_8_REV, logo_rgba->pixels);
}
Enter fullscreen mode Exit fullscreen mode

Most of the second block is just defining the sampling parameters for OpenGL. The most interesting call is the last line, where we pass off the actual pixel data from the SDL surface to the GPU.

Loops

We're just about done! Let's define our rendering pass, which is pretty straightforward because we have only one draw call.

void renderLoop(App* app) {
    glClearColor(1.0f, 0.0f, 0.0f, 0.0f);
    glClear(GL_COLOR_BUFFER_BIT);
    glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, NULL);
    SDL_GL_SwapWindow(app->m_window);
}
Enter fullscreen mode Exit fullscreen mode

Everything is already loaded (buffers, objects, program, etc.) so we just need a draw call. In this case, we tell OpenGL to draw six elements from our buffer (vertices), and to treat them as triangles (that is two triangles with three vertices each). Finally, we swap our buffer (I can't tell you how much time I've wasted on other projects before I realized nothing was showing because I never swapped the window buffers...).

Helpers

There are a few "helper" functions we need to define, as well as freeing our application state.

const char* getSource(const char* path) {
    // reads contents of file and returns the allocated character buffer
    std::ifstream file(path);
    if (!file.is_open()) {
        std::cerr << "Opening file failed!" << std::endl;
        return NULL;
    }
    std::stringstream buffer;
    buffer << file.rdbuf();
    std::string content = buffer.str();
    char* charBuffer = new char[content.size() + 1];
    std::copy(content.begin(), content.end(), charBuffer);
    charBuffer[content.size()] = '\0';
    return charBuffer;
}
Enter fullscreen mode Exit fullscreen mode

Lastly, let's define our our application state is cleaned up. This is basically in reverse order from our initialization.

void freeApplication(App* app) {
    glUseProgram(0);
    glDisableVertexAttribArray(0);
    glDetachShader(app->m_shader_prog, app->m_vert_shader);
    glDetachShader(app->m_shader_prog, app->m_frag_shader);
    glDeleteProgram(app->m_shader_prog);
    glDeleteShader(app->m_vert_shader);
    glDeleteShader(app->m_frag_shader);
    glDeleteTextures(1, &app->m_tex);
    glDeleteBuffers(1, &app->m_ebo);
    glDeleteBuffers(1, &app->m_vbo);
    glDeleteVertexArrays(1, &app->m_vbo);

    // invoke delete/destory methods for SDL state
    SDL_GL_DeleteContext(app->m_context);
    SDL_DestoryWindow(app->m_window);
    SDL_Quit();
}
Enter fullscreen mode Exit fullscreen mode

Shader

We're done! With the C++. We still need to define a very basic graphics pipeline. Let's start with the vertex shader, which is simply forwarding the texture coordinates as a varying parameter for the fragment shader, and defining the basic position transform from our 2d space into the 4d position OpenGL expects.

/**
 * basic.v.glsl
 */

in vec2 aXY;
in vec2 aUV;
varying vec2 vUV;

void main() {
    vUV = aUV;
    gl_Position = vec4(aXY, 0.0, 1.0);
}
Enter fullscreen mode Exit fullscreen mode

Then, our fragment shader uses those texture coordinates (interpolated for each pixel) to look up the appropriate fragment color from our texture data.

/**
 * basic.f.glsl
 */

varying vec2 vUV;
out vec4 oRGBA;
uniform sampler2D uTexture;

void main() {
    oRGBA = texture(uTexture, vUV);
}
Enter fullscreen mode Exit fullscreen mode

Building

We have enough! Let's compile our project using the CMake configuration we already set up.

cmake --build build
Enter fullscreen mode Exit fullscreen mode

If successful, you should see an executable show up in build/Debug/. And when you run it, you should see your sprite appear!

Stepping Back

We started this conversation off by saying "it's actually really easy to get started with OpenGL!"... but this took a little bit of time, didn't it?

If you think about what we were doing, none of these things were really optional--whether we're using OpenGL or anything else. Most importantly, we've put ourselves in a position where it's fairly extensible to more sophisticated to other things we might want to do. (We have image loading support, we have customizable shaders, we have structured state models, we have an extensible/threadable event loop...) Some of these came with optional dependencies (like SDL_Image) but this gave us a pretty well-organized "starter" project.

It will be very easy in our next iteration to break parts of this application structure apart into reusable models for shader programs, individual sprites, scene graph nodes with their own transforms, etc. This is the first of two big takeaways: With a little bit of help, you can get started with a sprite-based application very easily, and you can do it in a way where you make it possible to do a lot more in the future with that.

Image description

Secondly, believe it or not... well, there's a saying attributed to Winston Churchill, roughly along the lines of "democracy is the worst form of government... except for all the others that have been tried." OpenGL is a lot like this--trying to get started this quickly with any other approach is an absolute nightmare. OpenGL is the worst way to get started... except for all the others that have been tried. (Vulkan, Wayland, you name it.)

So, this is a little involved. But (maybe because I've just started at this too much over the years) everything here still makes sense. Compared to some of the more obscure setups, you're not trying to abstract away too much of what's going on with the GPU, you have a nicely customized graphics pipeline that you have a lot of control over but it's still straightfowrard to setup and get something going.

This is part one of two. In part two, I'm thinking of looking at a basic 2d engine that you might put together based off of this. But this is a good way to get going, and a good way to start doing quick 2d cross-platform applications, especially if you're new to it or just want to draw some sprites.

Top comments (3)

Collapse
 
xephun profile image
X. Ephun

I do think this is the "missing gap" for Vulkan and others. If you're referring to the bloke I think you're referring to, he has a point, but then again he's writing custom languages for his game engine so... yeah.

Collapse
 
ebcefeti profile image
E. B. Cefeti

The meme was a bit forced on this one. O_o

Collapse
 
jocomvag profile image
Jocom Vag

A bit long-winded. But I get the point. Have simply given up on Vulkan more than once.