Adding an OpenGL Backend
Replicating the functionality from the Metal backend in OpenGL
This is part of a series of hands-on tutorials. I will not be sharing copy-pasteable code; only screenshots. The idea is to get the reader to write their own code and run their own experiments.
In the previous episode we started the project with a Metal backend.
In this episode, we’ll implement the same functionality in OpenGL.
Here’s the answer to the homework from the previous episode:
We are using conditional compilation to decide whether to build the project with the Metal or the OpenGL backend.
There are two ways to switch to the OpenGL version:
We can pass
-define:Open_GL=true
to the odin run command,We can edit the config file and set the default value for USE_OPEN_GL to true.
Here’s the initial OpenGL code to create a graphics context and set a clear color
Next we’ll create shaders that draw a rectangle in in the middle of the screen:
There’s a lot of boilerplate code required to load a shader program. Luckily the OpenGL bindings that ship with the Odin compiler also ship with a helper function that loads shader programs for us.
Unlike the Metal API; there’s no simple error object. Reporting errors requires a lot of boilerplate code. Luckily again, the helper code that loads the program also handles printing the error messages on our behalf (when built in debug mode, which is the default if we don’t pass any optimization flags to the compiler).
Here are some of the error messages I got while working out how to write the shader code:
The shader code is different in how inputs and outputs are specified, but you can see how it essentially does the same thing as our metal shader code.
As with Metal, the concept of a vertex shader and fragment shader is the same: the vertex function runs once for every vertex and sets the vertex coordinates by setting the special gl_Position
variable. The id of the current vertex is given by the special gl_VertexID
variable. You can assosiate data with each vertex by specifying `out
` variables in the shader source.
The shader gets “wired” into the pipeline by the `gl.UseProgram
` call. But, to get it to execute, we need to tell OpenGL to draw a rectangle.
I can’t say that I understand what a “vertex array object” is, but you may notice that the draw command is called “DrawArrays”, so maybe if we don’t have a specific array object it doesn’t do anything.
Now, with this in place, we get the rectangle thing just like we did with Metal:
The next step is to pass the rectangle data from the draw_rect
calls.
To start with, I want to note that there’s no reason to have the draw_rect
call be platform specific. It just appends data to parallel arrays. I don’t see why anything about it needs to change depending on the graphics API. So I’m moving it out of the conditional compilation blocks.
So now, just like with Metal, we have three pieces of data to send: one is the screen size, the second is the array of rectangle placements, the other is the rectangle colors.
Now again, there are many ways to send data over to shaders in OpenGL. I can’t say I know which way is better, but I’m going with the “Uniforms” approach. The only potential problem with it is there is a limited amount of space (4096 floats) so if we want to draw more rectangles we have to send the data in batches.
First, here are the shaders:
They are basically a direct translation from the Metal version we saw last time.
A few things to notice:
Variables qualified with `
uniform
` recieve data from the CPU sideThe
color_idx
is qualified with `flat
` because it’s anint
and not meant to be interpolatedIn addition to the builtin
gl_VertexID
we are now also usinggl_InstanceID
Now for the CPU side, we need to use the gl.UniformNfv
procedures, where N is 1, 2, 3, or 4 to match the vecN type on the uniform array.
These family of procedures takes three parameters: the “location” of the uniform, the number of elements, and a pointer to the first float.
To get the location, we can use gl.GetUniformLocation. For uniforms that are arrays, we need to index with [0].
With this, we get the same result we got with metal: rectangles render correctly, but there’s no blending/opacity.
To get the blending options, all we need is:
And with this, we get the final result:
We’re basically done.
For more robustness, we should do the “batching” of commands so that we send only upto 512 rectangles per draw command. I’ll leave that as an exercise to the reader.
This episode was kind of a detour. I’m not sure that an OpenGL backend is a viable option in the long term. I just wanted to provide a basis for people who want to follow along who are not Mac users.
In the next episode, I will try to develop the drawing commands system and test it against various widget designs to see if we can replicate them.
See you next time