Learn how to get started with OpenGL as a high performance rendering library within your JUCE applications. Render beautiful 2D and 3D graphics in your audio apps and plugins.
Level: Advanced
Platforms: Windows, macOS, Linux, iOS, Android
Classes: OpenGLAppComponent, OpenGLContext, OpenGLShaderProgram, OpenGLHelpers, Matrix3D, Vector3D
This tutorial assumes basic understanding of the OpenGL graphics library. If you are not familiar with OpenGL, you should read about it first here.
Download the demo project for this tutorial here: PIP | ZIP. Unzip the project and open the first header file in the Projucer.
Resources
folder into the generated Projucer project.If you need help with this step, see Tutorial: Projucer Part 1: Getting started with the Projucer.
The demo project displays the standard OpenGL teapot object in 3D graphics by parsing a Wavefront ".obj" file as shown in the screenshot below:
Although the OpenGL API is a powerful and versatile library that works within many different platforms and build environments, the principles around 3D rendering remain similar across all applications. Some of the terms we are exploring here are fundamental to understanding how OpenGL performs its rendering routines:
The OpenGL shading language or GLSL is a C-type language that gives direct control over graphics rendering pipelines on multiple operating systems and hardware graphics cards. Using GLSL, we can write small programs called shaders that describe appearances of objects. Depending on whether we are using OpenGL or the subset library OpenGL ES designed specifically for embedded systems like smartphones and tablets, the language syntax remains the same but performance considerations need to be taken into account.
As an example, the vertex shader used in this tutorial looks like this:
And the fragment shader used in this tutorial looks like this:
As you can see the shaders are quite trivial and the differences between the OpenGL and OpenGL ES shaders are minimal. The GLSL types, variables and functions used here are the following:
vec2/vec4
: Represents a floating point vector with 2 or 4 components.mat4
: Represents a 4-by-4 floating point matrix.lowp
: Specifies a lower precision data type for OpenGL ES.attribute
: Represents a vertex-specific parameter.uniform
: Represents a global parameter describing the GL environment.varying
: Represents a shared parameter between the vertex and fragment shaders.gl_Position
: The transformed vertex position for the vertex shader to execute vertex manipulations.gl_FragColor
: The colour for the fragment shader to execute fragment manipulations.main()
: The main function is where the vertex or fragment shader computation is performed.In JUCE, the OpenGLAppComponent class is very similar to the AudioAppComponent class but instead it is used for graphical apps. When inheriting from the OpenGLAppComponent class, there are several functions that we have to override namely:
Now that we have explored the basics of OpenGL, let's start implementing the teapot rendering!
In order to decouple the calculation of projection and view matrices, we create two helper functions that returns these matrices for later use.
First we calculate the projection matrix using a frustum and the screen bounds as shown below:
A frustum is a shape cutout from a polygon by slicing it with two parallel planes and the Matrix3D class provides a handy function called fromFrustum()
that returns a matrix from one. In the function above:
fromFrustum()
function with width, height, near plane and far plane distances as arguments to retrieve the projection matrix. This gives us a perspective projection as opposed to an orthographic projection.Next, we calculate the view matrix using a rotation matrix to animate our teapot as shown below:
The mathematical computation part is complete and we can start writing the shader program next.
Let's start by defining some useful member variables that we will use throughout the tutorial code base:
Here we have defined several pointers to the shape, attributes and uniforms we will be using in this GL context as well as an OpenGLShaderProgram object that manages the shader program. We also have two char pointers to define the vertex shader and fragment shader as shown in the next step:
In the createShaders()
function, we first copy the previously shown shaders into the char pointers by inserting line breaks. This function will be later called in the initialise()
function of the OpenGLAppComponent. The vertex shader essentially sets the position of every vertex in the shape by setting the "gl_Position" variable to the product of the transformation matrices namely the projection matrix followed by the view matrix. As for the fragment shader, the colour of the pixel is specified by setting the "gl_FragColor" variable to the specified colour.
In the second half of the createShaders()
function, we create a new shader program within the current GL context [1] and perform some initialisation as follows:
Now let's define useful structures to represent vertices, attributes, uniforms and shapes.
In order to represent a vertex we require four important variables as shown below:
The attributes structure is essentially a container class to hold several OpenGLShaderProgram::Attribute objects together and the attributes we store are defined here:
They correspond exactly to the variables in the Vertex struct defined earlier since attributes are meant to describe vertex parameters in the vertex shader program.
As expected, we create these attributes in the constructor by calling the private helper function defined in the next step by passing the shader program as an argument:
The helper function in turn will call the OpenGLShaderProgram::Attribute constructor to instantiate new objects:
However, in the above we first check whether the attribute exists in the shader program by using the glGetAttribLocation()
function. If the number returned is -1 then we abort the attribute instantiation.
In the enable()
function, all the attributes are activated (after checking if they exist) by calling the glVertexAttribPointer()
and glEnableVertexAttribArray()
functions as shown below:
The glVertexAttribPointer()
function defines the array of vertex attribute data with information such as the index, size and type of data to hold. Notice that the last argument specifies the offset of the data cumulatively with regards to the other attributes defined previously in the structure. Then the glEnableVertexAttribArray()
function enables the actual array to be used within the context.
In the disable()
function, we do the exact opposite by calling the glDisableVertexAttribArray()
function on all attributes:
The uniforms structure similarly contains several OpenGLShaderProgram::Uniform objects in the same manner as defined here:
They correspond exactly to the matrix variables defined earlier in the vertex shader program.
As expected, we create these attributes in the constructor by calling the private helper function defined in the next step by passing the shader program as an argument:
The helper function in turn will call the OpenGLShaderProgram::Uniform constructor to instantiate new objects:
However, in the above we first check whether the uniform exists in the shader program by using the glGetUniformLocation()
function. If the number returned is -1 then we abort the uniform instantiation.
The shape structure is where we define the teapot object in OpenGL terms. The member variables are used to store the Wavefront Obj file for the teapot model and an array of vertex buffers defined as a sub-structure just below:
Let's first examine how a vertex buffer is defined. It essentially contains the total number of indices in the mesh as well as a vertex buffer and an index buffer in order to prepare for later rendering:
The class constructor initialises a vertex buffer in the following way:
glGenBuffers()
function and bind the vertex attributes to it with the glBindBuffer()
function.glBufferData()
function.The helper function that creates a mesh from a vertex list is defined as follows:
In the destructor, we delete the vertex and index buffers by calling the glDeleteBuffers()
function on each variable:
The bind()
function defined below is called when the shape is drawn and binds the vertex and index buffers using the glBindBuffer()
function:
Now let's go back to the shape constructor where the teapot binary data is loaded into the WavefrontObjFile variable:
Resources
folder of your project.If the loading is successful, we can iterate over every shape contained in the WavefrontObjFile object and we can create a new VertexBuffer object and add it to the vertex buffer array.
Finally, we implement a draw()
function that will be called in the render()
function of the OpenGLAppComponent later on as defined below:
For every vertex buffer in the member variable array, we first call the bind()
function to bind the vertex and index buffers to the GL context. We then call the enable()
function defined earlier on every attribute to fill the arrays with data. Finally, the glDrawElements
function draws every set of three vertices contained in the vertex buffer as triangles before the attributes are disabled and emptied.
We now have all the components to render our teapot so let's put it all together.
As mentioned before our app inherits from the OpenGLAppComponent class as shown here in the MainContentComponent
class:
In the class constructor, we set the size of our window as usual using the setSize()
function:
In the class destructor, we make sure that the OpenGL system is shutdown before our class is destroyed by calling the shutdownOpenGL()
function:
As described before the OpenGLAppComponent class provides startup and shutdown functions to facilitate implementation of our graphics application. In the initialise()
function we call the helper function createShaders()
defined earlier to prepare the vertex and fragment shaders as shown here:
As for the shutdown()
function, we ensure there is no leakage by setting all the member variable pointers to null as done here:
Next, we perform the actual rendering by overriding the OpenGLAppComponent::render()
function as explained below:
OpenGLHelpers::isContextActive()
function so that we can retrieve the scale factor of the rendering display.glEnable()
function activates the "GL_BLEND" option which blends the colour of the computed fragment colour with the colour buffer values. The blending method is specified in the glBlendFunc()
function by specifying the transparency calculation.glViewport()
function sets the viewport of the GL window relative to the device screen by multiplying the width and height by the rendering display scale factor.use()
function on the shader pointer, we specify which shader we want to use in this GL context.draw()
function defined earlier on the shape pointer to render the teapot within the GL context and attributes specified as arguments.glBindBuffer()
function on the GL context.In this tutorial, we have learnt how to set up an OpenGL JUCE application. In particular, we have: