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_()
Final notes
Here are some related interesting links: