DEV Community

Cover image for Dealing with OpenGL context and multiple threads
Madhav Goyal
Madhav Goyal

Posted on

Dealing with OpenGL context and multiple threads

Hi fellow devs!
I have been working on an OpenGL based desktop application for the past few months, and I obviously got stuck on the same problem everyone has to deal with, how to manage OpenGL context while making use of multiple threads.

Understanding OpenGL: The Basics

It would be better to explain some of OpenGL's concepts before we start writing code for multi-threaded systems.

OpenGL is a state machine

Any OpenGL function that you call, always affects the currently active context. Let me explain this with an example code block.

// vertex coords
float vertices[] = {...};

// Creating a vertex buffer
unsigned int vao, vbo;
glGenVertexArrays(1, &vao);
glGenBuffers(1, &vbo);

glBindVertexArray(vao);
glBindBuffer(GL_ARRAY_BUFFER, vbo);

// Copy vertices from main memory to GPU memory
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
...
Enter fullscreen mode Exit fullscreen mode

Now, in this example, the glBufferData does not take any input, that specifies which buffer it is operating on. OpenGL just executes this function on the buffer that was last bound with glBindBuffer call.

This is really important because it means that the state of OpenGL is context dependent. The target of next command is dependent on the previous command, multi-threading usually can't be implemented in such systems.

So, how does multi-threading work

For all threads (render and worker), assume the previous state is destroyed and reset it by binding the required variables before performing any OpenGL operation.

For eg.

// Render thread
while (!glfwWindowShouldClose(window)) {
        glClear(GL_COLOR_BUFFER_BIT);

        // Bind vertices, textures, shaders
        glUseProgram(...);
        glBindTexture(...);
        glUseProgram(...);
        glBindVertexArray(...);

        // Draw call
        glDrawElements(...);
}
Enter fullscreen mode Exit fullscreen mode
// Image loader (Worker) thread

// Creating texture
unsigned int texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexParameteri(...);
glTexParameteri(...);

// Load image data
unsigned char* imageData = stbi_load(...);

// Pass data to GPU
glTexImage2D(GL_TEXTURE_2D, ...);

delete[] imageData;
Enter fullscreen mode Exit fullscreen mode

This example would've almost worked, but since both threads run in parallel, one glBind<Anything> call could be overridden by another and lead to undefined behaviour.

You might look at this and think, this seems like the typical critical section problem and can be easily solved using a mutex. So, you add one and now it looks something like this.

#incude <mutex>

std::mutex GLMutex;
Enter fullscreen mode Exit fullscreen mode
// Render thread

while (!glfwWindowShouldClose(window)) {
+       // Lock the GLMutex before drawing
+       GLMutex.lock();

        glClear(GL_COLOR_BUFFER_BIT);

        // Bind vertices, textures, shaders
        glUseProgram(...);
        glBindTexture(...);
        glUseProgram(...);
        glBindVertexArray(...);

        // Draw call
        glDrawElements(...);

+       // Release the GLMutex
+       GLMutex.unlock();
}
Enter fullscreen mode Exit fullscreen mode
// Image loader (Worker) thread

+ // Lock GLMutex
+ GLMutex.lock();

// Creating texture
unsigned int texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexParameteri(...);
glTexParameteri(...);

// Load image data
unsigned char* imageData = stbi_load(...);

// Pass data to GPU
glTexImage2D(GL_TEXTURE_2D, ...);

+ // Release GLMutex
+ GLMutex.unlock();

delete[] imageData;
Enter fullscreen mode Exit fullscreen mode

Now, OpenGL should be able to work parallely across multiple threads. But, still... IT DOESN'T WORK.

OpenGL context

The reason it doesn't work is because OpenGL commands are dependent on a valid OpenGL context active in the current thread. The glfwCreateWindow command which you would've used somewhere in the beginning creates an OpenGL rendering context and that context is bound to the thread that calls it. There can be any number of OpenGL contexts in a program, but they aren't shared among threads (Doesn't mean you can't share them yourself).

In the last example, any OpenGL call made from the main thread was valid and working properly, but none of the OpenGL functions called from the worker thread were executed because it did not have a rendering context.

Now, since rendering context is also just a variable (GLFWwindow) you can also share it across threads using a mutex. And, it should just work when used like this

#incude <mutex>

std::mutex GLMutex;
Enter fullscreen mode Exit fullscreen mode
// Render thread

while (!glfwWindowShouldClose(window)) {
        // Lock the GLMutex before drawing
        GLMutex.lock();
+       glfwMakeContextCurrent(window);

        glClear(GL_COLOR_BUFFER_BIT);

        // Bind vertices, textures, shaders
        glUseProgram(...);
        glBindTexture(...);
        glUseProgram(...);
        glBindVertexArray(...);

        // Draw call
        glDrawElements(...);

        // Release the GLMutex
+       glfwMakeContextCurrent(NULL);
        GLMutex.unlock();
}
Enter fullscreen mode Exit fullscreen mode
// Image loader (Worker) thread

// Lock GLMutex
GLMutex.lock();
+ glfwMakeContextCurrent(window);

// Creating texture
unsigned int texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexParameteri(...);
glTexParameteri(...);

// Load image data
unsigned char* imageData = stbi_load(...);

// Pass data to GPU
glTexImage2D(GL_TEXTURE_2D, ...);

// Release GLMutex
+glfwMakeContextCurrent(NULL);
GLMutex.unlock();

delete[] imageData;
Enter fullscreen mode Exit fullscreen mode

This implementation is valid and should yield the desired results. However, sharing OpenGL contexts isn't a recommended approach since, they aren't inherently thread-safe and any improper management can lead to race conditions, undefined behavior, or crashes.
You should always avoid sharing context across threads unless absolutely necessary (which it isn't 😉).

The solution I've come up with

Disclaimer: Although I did come up with this solution on my own, I do not claim that no one else has ever done it before. (Couldn't find it anywhere I looked)

The key ideas

  • Instead of passing the context around, this method keeps the context on the main thread and passes the commands to it.
  • This method is assumes that the main thread maintains a high level of responsiveness (fast thread) and on the other hand, worker threads, which can be dedicated to processing CPU-intensive or I/O-bound operations, are allowed to take their time (slow thread). These threads can even wait for the main thread to complete its short, critical tasks without causing performance hiccups or delays in the overall system responsiveness.

Requirements

  1. There can be more than one places where a thread might want to call OpenGL functions so, their order of execution should be maintained when called from the main thread.

Eg.

// Worker
void ImageLoader() {
    /*
    ----------------------------------------
    OpenGL texture creation commands
    ----------------------------------------
    */

    // Load image data
    unsigned char* imageData = stbi_load(...);

    /*
    ----------------------------------------
    Setting image data on texture
    ----------------------------------------
    */

    delete[] imageData;

    ... Some other code ...
}
Enter fullscreen mode Exit fullscreen mode
  1. Reference or pointer variables should still be valid at the time of execution. Otherwise, they should be passed by value.

  2. It shouldn't require a very specific setup for every task.

Implementation

We first create an object that describes each OpenGL job task.

GLJob.h

#include <functional>
#include <mutex>

class GLJob {
  public:
    GLJob(std::function<void()> func,
          std::mutex *end = nullptr);

    void execute();
    void reject();

  private:
    std::mutex *endMutex;
    std::function<void()> jobFunc;
};
Enter fullscreen mode Exit fullscreen mode

GLJob.cpp

#include "GLJob.h"

GLJob::GLJob(std::function<void()> func, std::mutex *end)
    : endMutex(end), jobFunc(func) {}

void GLJob::execute() {
    jobFunc();
    if (endMutex)
        endMutex->unlock();
}

void GLJob::reject() {
    if (endMutex)
        endMutex->unlock();
}
Enter fullscreen mode Exit fullscreen mode

And a thread safe queue.

GLJobQ.h

#include <mutex>
#include <queue>
#include <memory>

class GLJobQ {
  public:
    void push(std::shared_ptr<GLJob> job);
    bool empty();
    std::shared_ptr<GLJob> pop();

  private:
    std::mutex qMutex;
    std::queue<std::shared_ptr<GLJob>> jobQ;
};
Enter fullscreen mode Exit fullscreen mode

GLJobQ.cpp

#include "GLJobQ.h"

void GLJobQ::push(std::shared_ptr<GLJob> job) {
    std::lock_guard<std::mutex> lock(qMutex);
    // bool shouldRun = Some function to check if job should be rejected or not
    if (!shouldRun) {
        job->reject();
    } else {
        jobQ.push(job);
    }
}

bool GLJobQ::empty() {
    std::lock_guard<std::mutex> lock(qMutex);
    return jobQ.empty();
}

std::shared_ptr<GLJob> GLJobQ::pop() {
    std::lock_guard<std::mutex> lock(qMutex);
    if (jobQ.empty())
        return nullptr;
    auto job = jobQ.front();
    jobQ.pop();
    return job;
}
Enter fullscreen mode Exit fullscreen mode

And that's it :)

Usage

  • Make a GLJobQ object in the main thread and make sure it's globally accessible.
  • From every other thread, just create a GLJob and push it to the GLJobQ.

Example

Let's assume we have an instance of Application object running on main thread and an ImageLoader function which runs on a separate thread

Application.h

#include "GLJobQ.h"

class Application {
    // Other members...

    // Application public members are globally accessible
  public:
    GLJobQ queue;

    void start();
}
Enter fullscreen mode Exit fullscreen mode

Application.cpp

void start() {
    while (!glfwWindowShouldClose(window)) {
        // Frame rendering...

        // Execute 1 GLJob per frame
        if (!queue.empty()) {
            queue.pop()->execute();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

ImageLoader.cpp

void ImageLoader() {
    // Lock to keep context active until all jobs are finished
    std::mutex finish;
    finish.lock();

    Application &app = // Get application ref

    GLuint tex;
    // Push a texture creation job
    std::shared_ptr<GLJob> textureJob = std::make_shared<GLJob>([&tex]() {
        glGenTextures(1, &tex);
        glBindTexture(GL_TEXTURE_2D, tex);

        glTexParameteri(...);
    });
    app.glJobQ.push(textureJob);

    unsigned char *imageData = stbi_load(...);

    // Push a texture assignment job
    std::shared_ptr<GLJob> textureAssignJob = std::make_shared<GLJob>([&tex, imageData]() {
        GLuint tex;
        glBindTexture(GL_TEXTURE_2D, tex);
        glTexImage2D(... data);

        delete[] data;
    },
    // Pass the mutex since this is last GLJob in this thread
    &finish);
    app.glJobQ.push(textureAssignJob);

    // Wait till all GLJobs are finished
    finish.lock();

    // Delete imageData only when all dependent GLJobs are finished
    delete[] imageData;
}
Enter fullscreen mode Exit fullscreen mode

It satisfies our Requirements

  1. Our first requirement was to have a system that runs separate blocks of commands sequentially from the main thread. To satisfy that, we've created a queue of commands.
  2. The worker threads might finish their execution before their jobs are finished. In such case, variables passed to GLJob as reference will be freed before access. To mitigate this problem, GLJob takes an optional variable of the finish mutex. This can be used to enforce the worker threads to wait for it's GLJobs to finish. Since the worker threads are Slow threads, they can afford to wait for main thread without affecting performance of the application.
  3. The GLJob object takes a lambda function with std::function<void()> signature. This way, it doesn't require any specific function or storage variable for different kinds of OpenGL tasks and every task handles itself.

Why it works? - OpenGL doesn't care which thread is making calls to OpenGL functions as long as that thread has the OpenGL context bound.

Top comments (0)