1
\$\begingroup\$

I am interested in rendering animated 2D vector graphics in python using PyOpenGL. As an initial test, I am trying to render thousands of pixel-perfect circles drawn using shaders. Here is the current code, which runs smoothly at 60 fps on my laptop and renders 500 static circles:

import math
import random
import sys
import time

import numpy as np
import OpenGL
from OpenGL.GL import *
import pygame
from pygame.locals import *

def createCircles(numCircles):
    circles = []
    minRadius = 5
    maxRadius = 20
    for i in range(numCircles):
        cx = random.random()*width
        cy = random.random()*height
        r = random.random()*(maxRadius - minRadius) + minRadius
        circles.append((cx, cy, r))
    return circles

def createShader(vsStr, fsStr):
    vs = glCreateShader(GL_VERTEX_SHADER)
    glShaderSource(vs, vsStr)
    glCompileShader(vs)
    check = glGetShaderiv(vs, GL_COMPILE_STATUS)
    if not(check):
        raise RuntimeError(glGetShaderInfoLog(vs))

    fs = glCreateShader(GL_FRAGMENT_SHADER)
    glShaderSource(fs, fsStr)
    glCompileShader(fs)
    check = glGetShaderiv(fs, GL_COMPILE_STATUS)
    if not(check):
        raise RuntimeError(glGetShaderInfoLog(fs))

    program = glCreateProgram()
    glAttachShader(program, vs)
    glAttachShader(program, fs)
    glLinkProgram(program)
    check = glGetProgramiv(program, GL_LINK_STATUS)
    if not(check):
        raise RuntimeError(glGetProgramInfoLog(program))

    return program

def updateBufferData(circles, vbo, tbo, ibo, xbo, vertices, texCoords, indices, radii, transforms, instances):
    for i, circle in enumerate(circles):
        cx, cy, r = circle
        vertices[i] = [-r, -r, r, -r, -r, r, r, r]
        texCoords[i] = [-1, -1, 1, -1, -1, 1, 1, 1]
        indices[i] = np.array([0, 1, 2, 2, 3, 1], dtype=np.float32)+(4*i)
        transforms[i] = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, cx, cy, 0, 1]
        instances[i] = [i]*4
        radii[i] = r
    glBindBuffer(GL_ARRAY_BUFFER, vbo)
    glBufferData(GL_ARRAY_BUFFER, vertices.nbytes, vertices, GL_STATIC_DRAW)
    glBindBuffer(GL_ARRAY_BUFFER, 0)
    glBindBuffer(GL_ARRAY_BUFFER, tbo)
    glBufferData(GL_ARRAY_BUFFER, texCoords.nbytes, texCoords, GL_STATIC_DRAW)
    glBindBuffer(GL_ARRAY_BUFFER, 0)
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo)
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.nbytes, indices, GL_STATIC_DRAW)
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0)
    glBindBuffer(GL_ARRAY_BUFFER, xbo)
    glBufferData(GL_ARRAY_BUFFER, instances.nbytes, instances, GL_STATIC_DRAW)
    glBindBuffer(GL_ARRAY_BUFFER, 0)

if __name__ == "__main__":
    width = 500
    height = 500
    title = "Pseudo-Instancing Demo"
    pygame.init()
    screen = pygame.display.set_mode((width, height), DOUBLEBUF|OPENGL|HWSURFACE)

    vsStr = """#version 120
    attribute vec2 a_position;
    attribute vec2 a_texCoord;
    attribute int a_instance;
    uniform mat4 u_transform[500];
    uniform vec4 u_color[500];
    uniform float u_radius[500];
    varying vec4 v_color;
    varying float v_radius;
    varying vec2 v_texCoord;
    void main()
    {
        gl_Position = gl_ProjectionMatrix * u_transform[a_instance] * vec4(a_position, 0, 1);
        v_texCoord = a_texCoord;
        v_color = u_color[a_instance];
        v_radius = u_radius[a_instance];
    }
    """

    fsStr = """#version 120
    varying vec4 v_color;
    varying float v_radius;
    varying vec2 v_texCoord;
    void main()
    {
        float d = v_radius - length(v_texCoord*vec2(v_radius, v_radius));
        float t = clamp(d, 0, 1);
        gl_FragColor = vec4(1, 1, 1, t) * v_color;
    }
    """

    numCircles = 500
    circles = createCircles(numCircles)
    shader = createShader(vsStr, fsStr)
    vbo, tbo, ibo, xbo = glGenBuffers(4)
    vertices = np.empty([numCircles, 8], dtype=np.float32)
    texCoords = np.empty([numCircles, 8], dtype=np.float32)
    indices = np.empty([numCircles, 6], dtype=np.int32)
    transforms = np.empty([numCircles, 16], dtype=np.float32)
    colors = np.random.rand(numCircles, 4)
    radii = np.empty(numCircles, dtype=np.float32)
    instances = np.empty([numCircles, 4], dtype=np.int32)
    glClearColor(1.0, 1.0, 1.0, 1.0)
    glMatrixMode(GL_PROJECTION)
    glOrtho(0, width, 0, height, -1, 1)
    glEnable(GL_BLEND)
    glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)

    while True:
        start = time.time()
        events = pygame.event.get()
        for event in events:
            if event.type == pygame.QUIT:
                sys.exit()

        updateBufferData(circles, vbo, tbo, ibo, xbo, vertices, texCoords, indices, radii, transforms, instances)

        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
        glUseProgram(shader)
        a_position = glGetAttribLocation(shader, "a_position")
        a_texCoord = glGetAttribLocation(shader, "a_texCoord")
        a_instance = glGetAttribLocation(shader, "a_instance")

        u_transform = glGetUniformLocation(shader, "u_transform")
        u_color = glGetUniformLocation(shader, "u_color")
        u_radius = glGetUniformLocation(shader, "u_radius")
        glUniformMatrix4fv(u_transform, numCircles, False, transforms)
        glUniform4fv(u_color, numCircles, colors)
        glUniform1fv(u_radius, numCircles, radii)

        glEnableVertexAttribArray(a_position)
        glEnableVertexAttribArray(a_texCoord)
        glEnableVertexAttribArray(a_instance)
        glBindBuffer(GL_ARRAY_BUFFER, vbo)
        glVertexAttribPointer(a_position, 2, GL_FLOAT, False, 0, None)
        glBindBuffer(GL_ARRAY_BUFFER, tbo)
        glVertexAttribPointer(a_texCoord, 2, GL_FLOAT, False, 0, None)
        glBindBuffer(GL_ARRAY_BUFFER, xbo)
        glVertexAttribPointer(a_instance, 1, GL_FLOAT, False, 0, None)
        glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo)
        glDrawElements(GL_TRIANGLES, 6*numCircles, GL_UNSIGNED_INT, None)
        glDisableVertexAttribArray(a_position)
        glDisableVertexAttribArray(a_texCoord)
        glDisableVertexAttribArray(a_instance)
        glUseProgram(0)

        pygame.display.flip()
        end = time.time()
        fps = int(round(1/(end - start)))
        pygame.display.set_caption(title + ": " + str(fps))

Unfortunately, the size of uniform arrays is fixed and needs to be declared at shader compile time, forcing me to declare a predefined array size. Furthermore, the size of the uniform array is limited as only 16384 uniform floats on my computer, as determined from a query to glGetIntegerv(GL_MAX_VERTEX_UNIFORM_COMPONENTS). Given 16 floats for each circles transform matrix, four floats to store a vec4 color, and 1 float to hold radius information (required for my screen-space anti-aliasing), I need to use 21 floats for each circle, resulting in me only being able to store data for a maximum of about 780 circles in the uniform arrays.

I am interested in rendering thousands of circles in a single draw call to take full advantage of my hardware and not impose any arbitrary rendering limits. As I am using OpenGL 2.1, I do not have access to hardware instance rendering. Currently, the workaround I have implemented is pseudo-instancing where I create use an attribute to store the instance id for each circle. This instance id serves as the index for a bunch of uniform arrays containing properties specific to each circle, such as transform, color, and radius. What would be the most performant way to replace uniform arrays for instance-specific data so that I can bypass the uniform array size limit? I have considered storing all of the instance data in a texture instead, but I am not sure how best to structure this data to fit into rgba components and how the data would be accessed by each instance in the shaders.

UPDATE: I have now replaced the uniform arrays with a single, 512x512 RGBA32 floating point textures that stores the data in blocks of 24 floats. Now, in the vertex shader, 4 texture reads are required for retrieving the modelview matrix, 1 for the color, and 1 more for the radius. While the code (shown below) works for more circles than previously allowed by uniform arrays, the performance degrades to 50 fps for the same 500 circles on my laptop. Here is the code:

import math
import random
import sys
import time

import numpy as np
import OpenGL
from OpenGL.GL import *
import pygame
from pygame.locals import *

def createCircles(numCircles):
    circles = []
    minRadius = 5
    maxRadius = 20
    for i in range(numCircles):
        cx = random.random()*width
        cy = random.random()*height
        r = random.random()*(maxRadius - minRadius) + minRadius
        circles.append((cx, cy, r))
    return circles

def createShader(vsStr, fsStr):
    vs = glCreateShader(GL_VERTEX_SHADER)
    glShaderSource(vs, vsStr)
    glCompileShader(vs)
    check = glGetShaderiv(vs, GL_COMPILE_STATUS)
    if not(check):
        raise RuntimeError(glGetShaderInfoLog(vs))

    fs = glCreateShader(GL_FRAGMENT_SHADER)
    glShaderSource(fs, fsStr)
    glCompileShader(fs)
    check = glGetShaderiv(fs, GL_COMPILE_STATUS)
    if not(check):
        raise RuntimeError(glGetShaderInfoLog(fs))

    program = glCreateProgram()
    glAttachShader(program, vs)
    glAttachShader(program, fs)
    glLinkProgram(program)
    check = glGetProgramiv(program, GL_LINK_STATUS)
    if not(check):
        raise RuntimeError(glGetProgramInfoLog(program))

    return program

def updateBufferData(circles, vbo, tbo, ibo, xbo, vertices, texCoords, indices, instances, colors, instanceData, texture):
    iData = np.empty(shape=(len(circles), 24), dtype=np.float32)
    for i, circle in enumerate(circles):
        cx, cy, r = circle
        vertices[i] = [-r, -r, r, -r, -r, r, r, r]
        texCoords[i] = [-1, -1, 1, -1, -1, 1, 1, 1]
        indices[i] = np.array([0, 1, 2, 2, 3, 1], dtype=np.float32)+(4*i)
        instances[i] = [i]*4
        iData[i] = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, cx, cy, 0, 1] + list(colors[i]) + [r, 0, 0, 0]

    iData = iData.flatten()
    instanceData = instanceData.flatten()
    instanceData[0:len(iData)] = iData
    instanceData = np.reshape(instanceData, (512, 512, 4))
    glActiveTexture(GL_TEXTURE0)
    glBindTexture(GL_TEXTURE_2D, texture)
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA32F, 512, 512, 0, GL_RGBA, GL_FLOAT, instanceData)
    glBindTexture(GL_TEXTURE_2D, 0)

    glBindBuffer(GL_ARRAY_BUFFER, vbo)
    glBufferData(GL_ARRAY_BUFFER, vertices.nbytes, vertices, GL_STATIC_DRAW)
    glBindBuffer(GL_ARRAY_BUFFER, 0)
    glBindBuffer(GL_ARRAY_BUFFER, tbo)
    glBufferData(GL_ARRAY_BUFFER, texCoords.nbytes, texCoords, GL_STATIC_DRAW)
    glBindBuffer(GL_ARRAY_BUFFER, 0)
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo)
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.nbytes, indices, GL_STATIC_DRAW)
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0)
    glBindBuffer(GL_ARRAY_BUFFER, xbo)
    glBufferData(GL_ARRAY_BUFFER, instances.nbytes, instances, GL_STATIC_DRAW)
    glBindBuffer(GL_ARRAY_BUFFER, 0)

if __name__ == "__main__":
    width = 500
    height = 500
    title = "Pseudo-Instancing Demo"
    pygame.init()
    screen = pygame.display.set_mode((width, height), DOUBLEBUF|OPENGL|HWSURFACE)

    vsStr = """#version 120
    attribute vec2 a_position;
    attribute vec2 a_texCoord;
    attribute int a_instance;
    uniform sampler2D u_texture;
    const int textureSize = 512;
    const int instanceSize = 24;
    varying vec4 v_color;
    varying float v_radius;
    varying vec2 v_texCoord;

    vec2 getIndex(int instanceNumber, int position)
    {
        int indexOneD = instanceNumber * instanceSize/4;
        float y = (indexOneD + position) / textureSize;
        float x = (indexOneD + position) - textureSize * y;
        return vec2(x/textureSize, y/textureSize);
    }

    void main()
    {
        vec4 rgba0 = texture2D(u_texture, getIndex(a_instance, 0));
        vec4 rgba1 = texture2D(u_texture, getIndex(a_instance, 1));
        vec4 rgba2 = texture2D(u_texture, getIndex(a_instance, 2));
        vec4 rgba3 = texture2D(u_texture, getIndex(a_instance, 3));
        vec4 rgba4 = texture2D(u_texture, getIndex(a_instance, 4));
        vec4 rgba5 = texture2D(u_texture, getIndex(a_instance, 5));
        mat4 transform = mat4(rgba0, rgba1, rgba2, rgba3);
        gl_Position = gl_ProjectionMatrix * transform * vec4(a_position, 0, 1);
        v_color = rgba4;
        v_radius = rgba5.r;
        v_texCoord = a_texCoord;
    }
    """

    fsStr = """#version 120
    varying vec4 v_color;
    varying float v_radius;
    varying vec2 v_texCoord;
    void main()
    {
        float d = v_radius - length(v_texCoord*vec2(v_radius, v_radius));
        float t = clamp(d, 0, 1);
        gl_FragColor = vec4(1, 1, 1, t) * v_color;
    }
    """

    numCircles = 500
    circles = createCircles(numCircles)
    shader = createShader(vsStr, fsStr)
    vbo, tbo, ibo, xbo = glGenBuffers(4)
    vertices = np.empty([numCircles, 8], dtype=np.float32)
    texCoords = np.empty([numCircles, 8], dtype=np.float32)
    indices = np.empty([numCircles, 6], dtype=np.int32)
    instances = np.empty([numCircles, 4], dtype=np.int32)

    colors = np.random.rand(numCircles, 4)
    instanceData = np.zeros(shape=(512, 512, 4), dtype=np.float32)
    texture = glGenTextures(1)
    glPixelStorei(GL_UNPACK_ALIGNMENT,1)
    glBindTexture(GL_TEXTURE_2D, texture)
    glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_NEAREST)
    glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_NEAREST)
    glBindTexture(GL_TEXTURE_2D, 0)

    glClearColor(1.0, 1.0, 1.0, 1.0)
    glMatrixMode(GL_PROJECTION)
    glOrtho(0, width, 0, height, -1, 1)
    glEnable(GL_BLEND)
    glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)

    while 1:
        start = time.time()
        events = pygame.event.get()
        for event in events:
            if event.type == pygame.QUIT:
                sys.exit()

        updateBufferData(circles, vbo, tbo, ibo, xbo, vertices, texCoords, indices, instances, colors, instanceData, texture)
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
        glUseProgram(shader)

        a_position = glGetAttribLocation(shader, "a_position")
        a_texCoord = glGetAttribLocation(shader, "a_texCoord")
        a_instance = glGetAttribLocation(shader, "a_instance")
        glActiveTexture(GL_TEXTURE0)
        glBindTexture(GL_TEXTURE_2D, texture)
        u_texture = glGetUniformLocation(shader, "u_texture")
        glUniform1i(u_texture, 0)

        glEnableVertexAttribArray(a_position)
        glEnableVertexAttribArray(a_texCoord)
        glEnableVertexAttribArray(a_instance)
        glBindBuffer(GL_ARRAY_BUFFER, vbo)
        glVertexAttribPointer(a_position, 2, GL_FLOAT, False, 0, None)
        glBindBuffer(GL_ARRAY_BUFFER, tbo)
        glVertexAttribPointer(a_texCoord, 2, GL_FLOAT, False, 0, None)
        glBindBuffer(GL_ARRAY_BUFFER, xbo)
        glVertexAttribPointer(a_instance, 1, GL_FLOAT, False, 0, None)
        glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo)
        glDrawElements(GL_TRIANGLES, 6*numCircles, GL_UNSIGNED_INT, None)
        glDisableVertexAttribArray(a_position)
        glDisableVertexAttribArray(a_texCoord)
        glDisableVertexAttribArray(a_instance)
        glBindTexture(GL_TEXTURE_2D, 0)
        glUseProgram(0)

        pygame.display.flip()
        end = time.time()
        fps = int(round(1/(end - start)))
        pygame.display.set_caption(title + ": " + str(fps))

Given that my laptop can render tens of thousands of sprites easily at a smooth 60fps in other tests such as this pixi.js webgl bunnymark, I am surprised that the fps dips down already for a mere 1000 circles. I have identified two potential bottlenecks in the code, but I am not sure how to approach them. I have listed them below.

  1. Numpy allocation of texture data: Perhaps in my updateBufferData function, the way in which I am storing the texture data is inefficient, as it requires two flattening operations and a reshape. Binding is also slow, but that cannot be avoided with this method. Maybe there is a different way to set the instance data inside this texture. I did see a slight performance boost when reducing the texture size from 1024x1024 to 512x512 pixels.

  2. Too many texture reads: Perhaps have six texture reads for each instance in the vertex shader is too expensive. Once again, I am not sure how I can reduce the overhead with this current setup.

Any help or alternative suggestions on how to go about rendering these objects would be greatly appreciated. I have also included a screenshot of the current result for anyone interested in the output.

enter image description here

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.