Stretching pyglet's Wings

Using pyglet to animate 2D vector graphics with OpenGL in Python.

This presentation, source code of the demos, and your comments can be found at:

http://tartley.com/?p=378

Jonathan Hartley, tartley at tartley.com

Motivation

Can we make 2D realtime vector graphic games in Python?

Problem: Python is slow - but maybe that doesn't matter?

Bonus: All the following is directly applicable to 3D too.

pyglet provides OpenGL bindings

from pyglet.gl import *
verts = [ (win.width * 0.9, win.height * 0.9),
          (win.width * 0.5, win.height * 0.1),
          (win.width * 0.1, win.height * 0.9), ]
colors = [ (255, 000, 000),
           (000, 255, 000),
           (000, 000, 255), ]
glBegin(GL_TRIANGLES)
for idx in len(verts):
    glColor3ub(colors[idx])
    glVertex2f(verts[idx])
glEnd()

Note OpenGL's static typed API leaks through. PyOpenGL is more Pythonic, but slower.

See Demo 1 (low fps: only redraws on events)

Demo 1

images/demo1.png

pyglet provides wrappers

Instead of:

glBegin(GL_TRIANGLES)
for idx in len(verts):
    glColor3ub(colors[idx])
    glVertex2f(verts[idx])
glEnd()

We can use:

pyglet.graphics.draw(3, GL_TRIANGLES,
    ('v2f', verts),
    ('c3B', colors), )

This removes our explicit Python iteration over vertices.

New problem: Co-ords depend on screen resolution.

Add a Camera class

Camera defines where we are looking at in our game world:

images/projection_matrix350.jpg

Camera class

A camera class needs the following state:

class Camera(object):
    def __init__(self, position, scale=1, angle=0):
        self.x, self.y = position  # centered on
        self.angle = 0             # tilt
        self.scale = scale         # zoom

Camera sets the OpenGL projection and modelview matrices.

All rendered vertex co-ords are transformed by these matrices.

So verts rendered in world co-ords are now translated, scaled and rotated to display at the right point on-screen.

Set Camera zoom

Camera sets up the projection matrix for 2D rendering, analogous to defining the properties of your camera, such as angle-of-view or zoom:

def focus(self, win_width, win_height):
    glMatrixMode(GL_PROJECTION)
    glLoadIdentity()
    aspect = win_width / win_height
    gluOrtho2D(-self.scale * aspect, # left
               +self.scale * aspect, # right
               -self.scale,          # bottom
               +self.scale)          # top

Set Camera pan and tilt

Next it sets the modelview matrix, analogous to aiming the camera at a particular point in space:

# Camera.focus() continued...

# Set modelview matrix to move, scale & rotate
glMatrixMode(GL_MODELVIEW)
glLoadIdentity()
gluLookAt(self.x, self.y, +1.0, # camera  x,y,z
          self.x, self.y, -1.0, # look at x,y,z
          sin(self.angle), cos(self.angle), 0.0)

See Demo 2 (now at 60fps, this redraws every frame)

Vertices are now in world-coords

Having a camera like this means our vertex co-ordinates no longer need to embed assumptions about our screen resolution:

camera = Camera((0, 0), scale=5)
verts = [
    +5, +4,
    +0, -4,
    -5, +4,
]
camera.focus(win.width, win.height)
draw(...)

Drawing more than one shape

A Shape class references a list of vertices, and stores the world co-ordinates at which we want to display it:

class Shape(object):

    def __init__(self, verts, colors, position, angle=0):
        self.verts = verts
        self.colors = colors
        self.x, self.y = position
        self.angle = angle

Several shapes may reference the same list of vertices

Same verts drawn at many locations

Rendering many shapes at different locations is now straightfoward:

for shape in shapes:
    glPushMatrix()
    glTranslatef(shape.x, shape.y, 0)
    glRotatef(shape.angle * rad2deg, 0, 0, 1)
    draw(3, GL_TRIANGLES,
        ('v2f', shape.verts),
        ('c3B', shape.colors),
    glPopMatrix()

These transformations act on the modelview matrix - the same one as used in the Camera class.

Triangles, again

Using the above, we can now draw a list of vertices in many different positions and orientations.

Tweaking a shape's x, y or angle attributes will move or spin it.

See Demo 3 (800 triangles at 40fps on my Thinkpad)

Demo 3

images/demo3.jpg

Enough with the triangles

A series of vertices such as ours can be drawn by OpenGL in one of several ways, known as primitives:

images/primitives.png

Surprise!

Modern consumer-level graphics cards are heavily optimised for indexed arrays of GL_TRIANGLES:

images/primitives-annotated.png

Composition of primitives

To draw interesting shapes, we'll need to compose several primitives into a single shape.

To help, replace our lists of vertices with Primitive objects.

A Primitive stores a single list of vertices, plus the OpenGL primitive type that should be used to render these vertices:

class Primitive(object):
    def __init__(self, verts, color, primtype):
        self.verts = verts
        self.color = color
        self.primitive = primtype # eg. GL_TRIANGLES

To render composite shapes

The Shape class now stores a list of Primitives, instead of a single list of verts.

Rendering a shape must now draw each of its primitives in succession:

def render(shape):
    for prim in shape.primitives:
        draw( len(primitive.verts),
              primitive.primtype,
              ('v2f', shape.verts),
              ('c3B', shape.colors), )

See Demo 4

Demo 4

images/demo4.png

Rendering quickly

OpenGL is much faster if vertices are passed to it in a packed array.

Such arrays can be created with ctypes, and passed directly to OpenGL functions.

Better though, pyglet provides convenient wrappers for this process: VertexLists

Vertex List

pyglet's draw() function is still calling glVertex() under the covers.

To eliminate the overhead of this iteration over each vertex, use VertexLists instead.

VertexLists take an array of vertices in their constructor.

On modern graphics cards, this array is stored in the graphic's hardware memory, meaning the vertex positions don't need to be sent to the graphics card every frame.

Using a vertex list

from pyglet.graphics import vertext_list
# create the list once at startup
vertexlist = vertex_list(
    len(primitive.verts),
    ('v2f\static', primitive.verts),
    ('c3B\static', primitive.colors),
)
...
# then render it using
vertexlist.draw(primitive.primtype)

Gotcha: don't forget the 'static' hint in vertex lists (and batches)

Demo 5

images/demo5.png

Demo 7 - to measure throughput

Demo 8 - flowers for prettyness

Batched Vertex Lists

Vertex lists can be batched into a single function call.

Each ghost can be drawn with a single batch.draw() call

Even better candidates for batching are background objects.

For example: pacman's maze can be rendered in a single batch

Gotcha: Vertex arrays passed to the creation of a batch need delimiting with duplicated start and end vertices

Creating a batch

self.batch = Batch()
for primitive in self.primitives:
    batchVerts = \
        [primitive.verts[0], primitive.verts[1]] + \
        primitive.verts + \
        [primitive.verts[-2], primitive.verts[-1]] ]
    numverts = len(batchVerts) / 2
    self.batch.add(
        numverts,
        primitive.primtype,
        None, # group
        ('v2f/static', batchVerts),
        ('c3B/static', primitive.color * numverts) )

Demo 9

images/demo9a.png images/demo9b.png

Reducing costs of game logic

Our biggest performance problem is no longer the graphics.

Python code for complex behaviours of in-game entities now dominates execution time.

One solution: Use C libraries for some of the grunt work.

Demo 9: SoleScion uses Chipmunk rigid body dynamics library to update positions of in-game entities.

Game Over

Thanks for listening!

Please come and chat or email me if you have related experience, projects or ideas: tartley at tartley dot com