2D graphics rendering tutorial with PyOpenGL

UPDATE: you may be interested in the Vispy library, which provides easier and more Pythonic access to OpenGL.

OpenGL is a widely used open and cross-platform library for real-time 3D graphics, developed more than twenty years ago. It provides a low-level API that allows the developer to access the graphics hardware in an uniform way. It is the platform of choice when developing complex 2D or 3D applications that require hardware acceleration and that need to work on different platforms. It can be used in a number of languages including C/C++, C#, Java, Objective-C (used in iPhone and iPad games), Python, etc. In this article, I'll show how OpenGL can be used with Python (thanks to the PyOpenGL library) to efficiently render 2D graphics.

Installation

One needs Python with the Numpy, PyOpenGL, and PyQt4 libraries. On Windows, binary installers can be found on this webpage.

In addition, the most recent drivers of the system graphics cards are needed, so that the latest implementation of OpenGL is available. In particular, we'll make use of vertex buffer objects (VBOs), that are available in the core implementation starting from OpenGL version 1.5 (which appeared in 2003). Graphics cards that were shipped after this date should have drivers that support VBOs. However, it is possible that the drivers installed on the system do not support a recent version of OpenGL.

For instance, on Windows, I had some issues with the default drivers of a 2009 graphics cards: OpenGL 1.1 was the only supported version. The reason is that Windows (starting from Vista) can use a sort of generic driver based on the Windows Display Driver Model (WDDM) when the constructor drivers are not found or not available. Now, the WDDM drivers tend to privilege DirectX (Microsoft's own graphics library, concurrent to OpenGL) rather than OpenGL, such that only very old versions of OpenGL are supported on those drivers. In order to make things work, one needs to find the constructor drivers and force their installation. It can be a bit painful.

In brief, if you have an error message mentioning OpenGL and buffer objects when running the script below, ensure that the graphics card drivers are the right ones. An extremely useful tool to check the OpenGL capabilities of the graphics card is OpenGL Extensions Viewer. It works on Windows, Linux, and iOS.

QGLWidget

We'll define a Qt widget that displays points at random positions in the window. This widget will derive from QGLWidget, a Qt widget that offers access to the OpenGL API for rendering. Three methods at least need to be overriden in the derived class: initializeGL(), updateGL(), and resizeGL(w, h).

  • initializeGL(): make here calls to OpenGL initialization commands. It is also the place for creating vertex buffer objects and populating them with some data.

  • paintGL(): make here calls to OpenGL rendering commands. It is called whenever the window needs to be redrawn.

  • resizeGL(w, h): make here calls related to camera and viewport. It is called whenever the size of the widget is changed (the new widget size are passed as parameters to this method).

Vertex Buffer Objects

The most efficient way of rendering data is to minimize the data transfers from system memory to GPU memory, and to minimize the number of calls to OpenGL rendering commands. A convenient way for doing this is to use Vertex Buffer Objects. They allow to allocate memory on the GPU, load data on the GPU once (or several times if the data changes), and render it efficiently since the data stays on the GPU between consecutives calls to paintGL(). PyOpenGL integrates a module to easily create and use VBOs:

import OpenGL.arrays.vbo as glvbo
# in initializeGL:
# create a VBO, data is a Nx2 Numpy array
self.vbo = glvbo.VBO(self.data)

# in paintGL:
# bind a VBO, i.e. tell OpenGL we're going to use it for subsequent
# rendering commands
self.vbo.bind()

Painting with VBOs

OpenGL can render primitives like points, lines, and convex polygons. The glEnableClientState and glVertexPointer functions configure the VBO for rendering, and the glDrawArrays function draws primitives from the buffer stored in GPU memory. Other drawing commands that can be used with a VBO include glMultiDrawArrays for plotting several independent primitives from a single VBO (which is more efficient, but less flexible, than using several VBOs). Indexed drawing is also possible and allows to use vertices in arbitrary order, and to reuse vertices several times during rendering. The relevant functions are glDrawElements and glMultiDrawElements.

Color can be specified either before calling the rendering commands, with the function glColor, or by creating a special VBO for colors, containing the colors of every point. The relevant functions are glColorPointer and glEnableClientState(GL_COLOR_ARRAY). A variant consists in packing the colors with the vertices, i.e. having 5 numbers per point in a single VBO (x, y coordinates and R, V, B color components). See some details here.

Note: apparently, in OpenGL, using single precision floating point numbers is better than using double precision float point numbers. The graphics card may not indeed support the latter format. I used doubles in an early version of this post and I had some nasty memory access violation crashes in particular cases. They disappeared when I switched to floats. If this is helpful to anyone...

# in paintGL:
# set the color yellow
gl.glColor(1,1,0)
# enable the VBO
gl.glEnableClientState(gl.GL_VERTEX_ARRAY)
# tell OpenGL that each vertex is made of 2 single precision floating
# numbers (x and y coordinates).
gl.glVertexPointer(2, gl.GL_FLOAT, 0, self.vbo)
# draw all points from the VBO
gl.glDrawArrays(gl.GL_POINTS, 0, len(self.data))

Setting orthographic projection for 2D rendering

The resizeGL method sets the geometric projection used for rasterization. Since we're only interested in 2D rendering in this article, we're using orthographic projection with the glOrtho function. The glViewport function allows to specify the part of the screen used for the subsequent rendering commands. Here we just tell OpenGL to draw within the entire window.

# paint within the whole window
gl.glViewport(0, 0, self.width, self.height)
# set orthographic projection (2D only)
gl.glMatrixMode(gl.GL_PROJECTION)
gl.glLoadIdentity()
# the window corner OpenGL coordinates are (-+1, -+1)
gl.glOrtho(-1, 1, 1, -1, -1, 1)

Setting the PyQt widget

Here we use PyQt as a GUI window system. To show a window on the screen and use our OpenGL widget, we first need to define a Qt main window, put the OpenGL widget inside, and finally create a Qt application to host the main window.

# define a Qt window with an OpenGL widget inside it
class TestWindow(QtGui.QMainWindow):
    def __init__(self):
        super(TestWindow, self).__init__()
        # initialize the GL widget
        self.widget = GLPlotWidget()
        # [...] (set data for the OpenGL widget)
        # put the window at the screen position (100, 100)
        self.setGeometry(100, 100, self.widget.width, self.widget.height)
        self.setCentralWidget(self.widget)
        self.show()

# create the Qt App and window
app = QtGui.QApplication(sys.argv)
window = TestWindow()
window.show()
app.exec_()

Full script

Here is the full script.

# PyQt4 imports
from PyQt4 import QtGui, QtCore, QtOpenGL
from PyQt4.QtOpenGL import QGLWidget
# PyOpenGL imports
import OpenGL.GL as gl
import OpenGL.arrays.vbo as glvbo

class GLPlotWidget(QGLWidget):
    # default window size
    width, height = 600, 600

    def set_data(self, data):
        """Load 2D data as a Nx2 Numpy array.
        """
        self.data = data
        self.count = data.shape[0]

    def initializeGL(self):
        """Initialize OpenGL, VBOs, upload data on the GPU, etc.
        """
        # background color
        gl.glClearColor(0,0,0,0)
        # create a Vertex Buffer Object with the specified data
        self.vbo = glvbo.VBO(self.data)

    def paintGL(self):
        """Paint the scene.
        """
        # clear the buffer
        gl.glClear(gl.GL_COLOR_BUFFER_BIT)
        # set yellow color for subsequent drawing rendering calls
        gl.glColor(1,1,0)
        # bind the VBO
        self.vbo.bind()
        # tell OpenGL that the VBO contains an array of vertices
        gl.glEnableClientState(gl.GL_VERTEX_ARRAY)
        # these vertices contain 2 single precision coordinates
        gl.glVertexPointer(2, gl.GL_FLOAT, 0, self.vbo)
        # draw "count" points from the VBO
        gl.glDrawArrays(gl.GL_POINTS, 0, self.count)

    def resizeGL(self, width, height):
        """Called upon window resizing: reinitialize the viewport.
        """
        # update the window size
        self.width, self.height = width, height
        # paint within the whole window
        gl.glViewport(0, 0, width, height)
        # set orthographic projection (2D only)
        gl.glMatrixMode(gl.GL_PROJECTION)
        gl.glLoadIdentity()
        # the window corner OpenGL coordinates are (-+1, -+1)
        gl.glOrtho(-1, 1, 1, -1, -1, 1)

if __name__ == '__main__':
    # import numpy for generating random data points
    import sys
    import numpy as np
    import numpy.random as rdn

    # define a Qt window with an OpenGL widget inside it
    class TestWindow(QtGui.QMainWindow):
        def __init__(self):
            super(TestWindow, self).__init__()
            # generate random data points
            self.data = np.array(.2*rdn.randn(100000,2),dtype=np.float32)
            # initialize the GL widget
            self.widget = GLPlotWidget()
            self.widget.set_data(self.data)
            # put the window at the screen position (100, 100)
            self.setGeometry(100, 100, self.widget.width, self.widget.height)
            self.setCentralWidget(self.widget)
            self.show()

    # create the Qt App and window
    app = QtGui.QApplication(sys.argv)
    window = TestWindow()
    window.show()
    app.exec_()

PyOpenGL tutorial

Final notes

Here are some related interesting links: