Custom data visualization with PyOpenGL and PyQt — Part 2 — First Shape
In part 1 we covered how to create an empty PyQt window and PyOpenGL widget. In part 2 we will cover how to draw a basic shape in PyOpenGL. To achieve this we’ll need to learn how to define buffer objects, transfer vertex data to the GPU via OpenGL, write and compile basic shaders and finally refresh the OpenGL widgets screen.
Defining the basic shape
In part 2, we’ll be drawing a triangle on screen, which is one of the OpenGL primitives. To draw the triangle, we’ll first have to define a list of coordinates.
In part 1 we left an empty implementation of the intializeGL method in our glWidget class. We’ll first add the definition on the coordinates of the triangle we want to draw in our OpenGL widget.
# define the vertices to be drawn
vertices = [[-0.5, -0.5, 0.0],
[0.5, -0.5, 0.0],
[0.0, 0.5, 0.0]]
To define a triangle we need three 3D coordinates. Because we’re drawing a 2D scene, all z axis coordinates are kept at the value 0. To define the x and y coordinates we need to understand the OpenGL coordinates system. In OpenGL the x,y and z coordinates range from -1 to 1. The bottom of the screen is y=-1 and the top of the screen is y=1. The left side of the screen is x=-1 and the right side of the screen is x=1.
Note: In part 1 we imported NumPy to our project. NumPy is required because the coordinates list has to be converted into a NumPy array when it is passed to a PyOpenGL function later on.
Writing a shader program
To define for the GPU how to convert the vertex data into a shape we need to implement to shaders. Shaders are essentially a program that runs on the GPU and converts the data transferred to the GPU into the pixels which are later displayed by OpenGL. The simplest OpenGL shader program consists of the shaders: the vertex shader and the fragment shader. The vertex shader defines how to manipulate the vertices passed to the GPU, and the fragment shader defines how to use the data passed from the vertex shader to render a pixel on screen. Finally, the shader program consists of the shaders pipeline, where there is an input of data which is passed to the vertex shader, which in turn passes that data to the fragment shader.
Shaders are implemented in a language called GLSL, which has a syntax very similar to C. We won’t dive into the syntax, because it’s easy enough to understand.
Vertex shader implementation:
# vertex shader code
vertexShaderCode = """#version 420 core \n
layout (location = 0) in vec3 aPos;\n
void main()\n
{\n
gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);\n
}"""
The vertex shader code is defined in the vertexShaderCode string.
- First line defines the OpenGL version number we’re using.
- Next line defines the input variables that are being sent to the shader. For now we have only a single input on location = 0. We’ll cover the location keyword in the following parts, where we’ll define more complex vertex shaders.
- Finally in the main function we define how the vertex data is converted into gl_position, which is a 4D variable. Hence, we cast it from a 3D vector into a 4D vector.
Fragment shader implementation:
# fragment shader code
fragmentShaderCode = """#version 420 core\n
out vec4 FragColor;\n
void main()\n
{\n
FragColor = vec4(0.7f, 0.0f, 0.0f, 1.0f);\n
}"""
The fragment shader code is defined in the fragmentShaderCode string. The fragment shader defines how to color the shape drawn within the coordinates passed to the vertex shader. Our simple fragment shader defines a constant color output for the shape drawn. Notice that the color is composed of three RGB float values and one alpha value.
Compiling the shader program
The final step in implementing a shader program is to compile the two shaders and build the shader program using the two shader we implemented. Seeing compiling the shaders will occur only once in the lifetime our OpenGL widget, it makes sense that this process will be defined in the initializeGL method.
First we’ll compile the vertex shader:
# compiling the shaders
vertexShader = glCreateShader(GL_VERTEX_SHADER)
glShaderSource(vertexShader, vertexShaderCode)
glCompileShader(vertexShader)
Next we’ll compile the fragment shader:
fragmentShader = glCreateShader(GL_FRAGMENT_SHADER)
glShaderSource(fragmentShader, fragmentShaderCode)
glCompileShader(fragmentShader)
We can check the compilation status, using the following calls:
if not glGetShaderiv(vertexShader, GL_COMPILE_STATUS, None):
print("vertexShader compilation failed")
if not glGetShaderiv(fragmentShader, GL_COMPILE_STATUS, None):
print("fragmentShader compilation failed")
In our case once we’ve already implemented two working shaders, these commands are not necessary, but they are very useful for debug.
Finally, we will build the shader program as follows:
# build the shader program
self.shaderProgram = glCreateProgram()
glAttachShader(self.shaderProgram, vertexShader)
glAttachShader(self.shaderProgram, fragmentShader)
glLinkProgram(self.shaderProgram)
Same as for compiling we can also check if out complete shader program build succeeded:
# check shader program status
if glGetProgramiv(self.shaderProgram, GL_LINK_STATUS, None) == GL_FALSE:
print("Shader program build failed")
Defining the vertex array object (VAO) and vertex buffer object (VBO)
Now that we’ve define the vertices and a way to draw them on screen using our shaders, we need to somehow send the data to the GPU. To do that we need to define a vertex attributes object (VAO) and a vertex buffer object (VBO). A VAO defines how to access and parse the data passed to the GPU via the VBO.
First we’ll define a single VAO and VBO and bind them together:
# define VAO and VBO and draw the triangle
self.VAO = glGenVertexArrays(1)
glBindVertexArray(self.VAO)
# Bind the VAO firsr, then bind VBO and configure the vertex attributes
VBO = glGenBuffers(1)
glBindBuffer(GL_ARRAY_BUFFER, VBO)
Next, we’ll define via a VAO how to parse the data transferred to the GPU:
# specify via VAO how the data in VBO should be used (vertex attributes)
glVertexAttribPointer(0, 3, GL_FLOAT, False, 3 * 4, ctypes.c_void_p(0))
glEnableVertexAttribArray(0)
1. The first param is the location of the vertex data, in our case for each vertex we’re passing just the coordinates and we don’t have any additional information passed for that vertex. Hence the location is 0.
2. Second param defines the size of the vertex attribute. In our case each vertex is defined by a 3D coordinate.
3. Third param defines the type of data passed.
4. Fourth param defines if we want the data to be normalized.
5. Fifth parameter defines the data size stride, in our case it’s 12. Define by float size and number of elements in each vertex.
6. Last parameter is the pointer to the first position in the array of data we’re accessing. In our case we’re using this weird ctypes cast, because there’s no such thing as pointers in Python.
Finally, we’ll attach the array of vertices we’ve defined earlier to a VBO:
# send vertex data to the GPU via a vertex buffer object
glBufferData(GL_ARRAY_BUFFER, 9 * 4, np.array(vertices, dtype=np.float32), GL_STATIC_DRAW)
1. First param defines the type of buffer passed.
2. Second param defines the size of the buffer.
3. Third param is data passed. Here we need to convert the vertices list object into a numpy array.
4. Fourth param defines how we want the GPU to manage the data. As the data in the buffer will not change we’ll keeps this defined as GL_STATIC_DRAW. For a buffer with data which updates frequently we can use GL_DYNAMIC_DRAW for GPU optimization.
As the data in our case is passed to the GPU only once, we’ll define this part also within the initializeGL method.
Drawing the shape
All is left to do in the OpenGL widget is to define how to draw the scene in the paintGL method:
def paintGL(self):
# override paintGL method to customize how to draw on screen
glClearColor(0.0, 0.0, 0.5, 1.0)
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
glUseProgram(self.shaderProgram)
glBindVertexArray(self.VAO)
# draw the triangle
glDrawArrays(GL_TRIANGLES, 0, 3)
glBindVertexArray(0)
glUseProgram(0)
First we select the shader program and VAO, and then we call glDrawArrays which in our case use triangles as primitives.
Refreshing the screen
Lastly, we’ll add a way to refresh the PyQt widget periodically. It’s not very useful , but it’ll become useful later on, once we start updating the OpenGL widget periodically.
To update the OpenGl widget periodically from the main window, we’ll use the following:
timer = QtCore.QTimer(self)
timer.timeout.connect(self.gl.update)
timer.setInterval(100)
timer.start()
We add a QTimer object which upon timeout calls the glwidget update method, which in turn calls the PaintGL method and refreshes the screen. For now the refresh rate is every 100 [ms].
After adding all the components and running the code we should see the following image:
You can find the full version of the code in the following git repository: link
Summary
In part 2 we added our first shaders and drawn our first basic shape in our OpenGL widget. In part 3, we’ll implement our first data visualization widget, read data from a file, and update the widget periodically.