badd10de.dev

Micro Interactive C Framework: OpenGL example


The Micro Interactive C (MIC) framework can be used to bootstrap C projects that rely on user interaction, such as games, real-time applications, video players, etc. These applications usually will have a main loop that will be iterated until being closed. MIC offers hot code reloading, being able to use C as a pseudo-scripting language. For technical details and an initial rundown of its usage, please see the README. Here we are going to be looking at an example of bootstraping an OpenGL application and modifying it on the fly.

Initial setup and window initialization

We will start by obtaining the MIC repository:

git clone https://git.badd10de.dev/mic mic_opengl
cd mic_opengl

For OpenGL support and window/input handling we will be using GLEW and GLFW. Install these libraries, make sure they are available in the import path and modify the Makefile to link them to the project:

LDFLAGS := -lGL -lGLEW -lglfw

Next, we will update the AppState struct in src/app.h to include the necessary fields for this project. We don’t really need the long/short term memory so we can get rid of it for now. We also need to import the GLEW and GLFW headers.

#include <GL/glew.h>
#include <GLFW/glfw3.h>

#include "shorthand.h"
#include "platform.h"

typedef struct AppState {
    // OpenGL.
    GLFWwindow *window;
} AppState;

In the app_init function found in str/app.c, we initialize GLFW, an OpenGL context, a GLFW window and some memory for later:

static inline bool
app_init(AppState *state, PlatformAPI platform) {
    platform.log("INIT");

    // Initialize GLFW.
    if (!glfwInit()) {
        platform.log("ERROR: failed to initialize GLFW");
        return false;
    }
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);

    // // Initialize window.
    GLFWwindow* window = glfwCreateWindow(800, 600, "Hello MIC", NULL, NULL);
    if (window == NULL) {
        platform.log("ERROR: failed to create GLFW window");
        glfwTerminate();
        return false;
    }
    glfwMakeContextCurrent(window);

    // Initialize GLEW.
    if(glewInit() != GLEW_OK) {
        platform.log("ERROR: glew initialization");
        glfwTerminate();
        return false;
    }

    // Initialize viewport.
    glViewport(0, 0, 800, 600);

    // Initialize application state.
    state->window = window;

    return true;
}

Finally, we go to the app_step function to clear the screen color and swap the framebuffer:

static inline bool
app_step(AppState *state, PlatformAPI platform) {
    (void)platform; // Unused parameter.
    if (glfwWindowShouldClose(state->window)) {
        return false;
    }

    glClearColor(1.0f, 0.0f, 0.4f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT);

    glfwSwapBuffers(state->window);
    glfwPollEvents();

    return true;
}

If we compile and run the code now, we should be seeing a red window:

make && ./build/app

Try modifying glClearColor and recompiling while the application is running to test the interactivity.

Example of MIC usage with OpenGL by opening a window, changing the background color and re-compiling

Rendering a triangle

To render a triangle in OpenGL, we will need a set of vertices, a vertex buffer object (VBO), a vertex array object (VAO) and a shader program. With this in mind, let’s update the AppState structure to include these elements.

typedef struct AppState {
    // OpenGL.
    GLFWwindow *window;
    f32 vertices[12];
    u32 VBO;
    u32 VAO;
    u32 shader_program;
} AppState;

Let’s also create a helper function for shader program compilation. This function will take two filesystem paths for the vertex and fragment shaders, will try to read these files from disk and compile the shader program, storing its handle if successful and returning the success/failure of the operation.

bool
compile_shaders(const char *vert, const char *frag, u32 *handle,
        PlatformAPI platform) {
    int success = 0;
    char memory[MB(1)] = {0}; // Memory for reading shader files.

    // Initialize shader handles.
    u32 vert_handle = glCreateShader(GL_VERTEX_SHADER);
    u32 frag_handle = glCreateShader(GL_FRAGMENT_SHADER);

    // Vertex shader.
    {
        platform.read_file(vert, memory);
        const char * source = memory;
        glShaderSource(vert_handle, 1, &source, NULL);
        glCompileShader(vert_handle);
        glGetShaderiv(vert_handle, GL_COMPILE_STATUS, &success);
        if (!success) {
            glDeleteShader(vert_handle);
            glDeleteShader(frag_handle);
            return false;
        }
    }

    // Fragment shader.
    {
        platform.read_file(frag, memory);
        const char * source = memory;
        glShaderSource(frag_handle, 1, &source, NULL);
        glCompileShader(frag_handle);
        glGetShaderiv(frag_handle, GL_COMPILE_STATUS, &success);
        if (!success) {
            glDeleteShader(vert_handle);
            glDeleteShader(frag_handle);
            return false;
        }
    }

    // Program linkage.
    *handle = glCreateProgram();
    glAttachShader(*handle, vert_handle);
    glAttachShader(*handle, frag_handle);
    glLinkProgram(*handle);
    glGetProgramiv(*handle, GL_LINK_STATUS, &success);
    if(!success) {
        glDeleteProgram(*handle);
        glDeleteShader(vert_handle);
        glDeleteShader(frag_handle);
        return false;
    }
    glDeleteShader(vert_handle);
    glDeleteShader(frag_handle);

    return true;
}

We create a directory in our path for the following vertex and fragment shaders:

// shaders/triangle.vert
#version 330 core
layout (location = 0) in vec3 vertex;
void main() {
    gl_Position = vec4(vertex.x, vertex.y, vertex.z, 1.0);
}

// shaders/triangle.frag
#version 330 core
out vec4 frag_col;
void main() {
    frag_col = vec4(0.8f, 0.8f, 0.8f, 1.0f);
}

Next we have to update the app_init function to generate the VBO and VAO, save the vertex data to the AppState struct and compile the shader program:

    // Initialize vertex data.
    state->vertices[0] = -0.5f;
    state->vertices[1] = -0.5f;
    state->vertices[2] = 0.0f;
    state->vertices[3] = 0.5f;
    state->vertices[4] = -0.5f;
    state->vertices[5] = 0.0f;
    state->vertices[6] = 0.0f;
    state->vertices[7] = 0.5f;
    state->vertices[8] = 0.0f;

    // Initialize Vertex Buffer Object (VBO) and Vertex Array Object (VAO).
    u32 VBO;
    glGenBuffers(1, &VBO);
    u32 VAO;
    glGenVertexArrays(1, &VAO);

    // Bind the VAO before any other binding.
    glBindVertexArray(VAO);

    // Send vertex data to VBO.
    glBindBuffer(GL_ARRAY_BUFFER, VBO);
    glBufferData(GL_ARRAY_BUFFER, sizeof(state->vertices), state->vertices,
        GL_STATIC_DRAW);

    // Set vertex attribute pointers.
    glEnableVertexAttribArray(0);
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), 0);

    // Compile shader program.
    const char *vert = "shaders/triangle.vert";
    const char *frag = "shaders/triangle.frag";
    if (!compile_shaders(vert, frag, &state->shader_program, platform)) {
        platform.log("WARNING: failed to compile shader program");
    }

    // Initialize application state.
    state->window = window;
    state->VBO = VBO;
    state->VAO = VAO;

The only thing left to do now is to draw the mesh in the app_step function.

static inline bool
app_step(AppState *state, PlatformAPI platform) {
    (void)platform; // Unused parameter.

    glClearColor(0.8f, 0.0f, 0.4f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT);
    glUseProgram(state->shader_program);
    glBindVertexArray(state->VAO);
    glDrawArrays(GL_TRIANGLES, 0, 3);

    glfwSwapBuffers(state->window);
    glfwPollEvents();

    return true;
}

Running this app now, we should have a white triangle over a red background. The shaders are not being updated while the application is reloaded, but this is an easy fix. We just have to recompile the shader program on the app_reload function.

static inline void
app_reload(AppState *state, PlatformAPI platform) {
    platform.log("RELOAD");
    glDeleteProgram(state->shader_program);
    const char *vert = "shaders/triangle.vert";
    const char *frag = "shaders/triangle.frag";
    if (!compile_shaders(vert, frag, &state->shader_program, platform)) {
        platform.log("WARNING: failed to compile shader program");
    }
}

Lastly, we make sure that make is monitoring the files in the shader directory for changes:

SRC_DIR        := src
SHADERS_DIR    := shaders
SRC_MAIN       := $(SRC_DIR)/main.c
SRC_APP        := $(SRC_DIR)/app.c
WATCH_SRC      := $(wildcard $(SRC_DIR)/*.c)
WATCH_SRC      += $(wildcard $(SRC_DIR)/*.h)
WATCH_SRC      += $(wildcard $(SHADERS_DIR)/*.vert)
WATCH_SRC      += $(wildcard $(SHADERS_DIR)/*.frag)

Now you can preview shader changes in real time! Isn’t that neat?

Example of MIC usage with OpenGL rendering a triangle, changing the fragment shader and background color and re-compiling

Wrapping up

This is just an example of what is possible with this little framework. There are no plans to expand it significantly, as it is supposed to be used as a starting point of more complex projects. However, I wouldn’t mind to add support for other platforms, such as Windows.

If you find any errors in this article or you want to contribute, you can drop me an email. You can also submit patches to MIC via git-send-email to the same address.

The code for this article can be found here, and both this example and the MIC framework is freely available under Unlicense/MIT, choose whichever you prefer.