steps3D - Tutorials - Using modern OpenGL with python

Использование современного OpenGL из python 3

Много лет я писал код под OpenGL исключительно на С++. Для удобства работ я использовал набор своих классов (таких как GlutWindow, Program, Texture и т.п.). На сайте есть старая статья про использование OpenGL из python, но она старая и давно устаревшая - там рассматривается OpenGL 2 и python 2.

При этом я часто использую python и он мне сильно нравится - он очень простой и удобный, крайне выразительный язык с огромным набором библиотек. Наличие встроенного мкенеджера пакетов pip сильно упрощает работу с многочисленными библиотеками и модулями (а также их зависимостями). Кроме того, многие небольшие примеры гораздо проще и быстрее написать на python, чем на С++. И довольно часто скорость приложения определяется скорее на стороне GPU, поэтому использование python не ведет к заметному замеделению программы, но упрощает установку и настройку многочисленных используемых библиотек.

Поэтому вполне естественно что в какой-то момент я решил посмотреть а что сеййчас в python с поддержкой соврменного OpenGL (3.х и выше). И был приятно удивлен, увидев что все просто отлично. Есть готовый модуль pyopengl, дающий непосредственно поддержку современного OpenGL. Кроме него я пордобрал для себя небольшой набор вспомгательный (и очень удобный) библиотек, заметно упрощающих работу с OpenGL из python.

Важной частью написания приложения, использующего OpenGL, является взаимодействие с оконной системой - создание и уничтожение окон, изменение их свойств, обработка событий (от мыши, клавиатуры и т.п.). Для этого я решил попробовать glfw - обертку для популярной библиотеки GLFW. Особенностью данной библиотеки является то, что все имена функций были заменены на python style, т.е. например вместо glfwCreateWindow нужно использовать glfw.create_window.

Есть хорошая обертка над библиотекой GLM для python - pyglmю Она позволяет легко работать со всеми типами данных из GLM (вектора, матрицы, кватрнионы). Обратите внимание, что при передаче этих типов данных в качестве uniform-переменных через pyopengl необходимо преобразовать их в tuple через метод to_tuple.

Для загрузки текстур из таких форматов как JPG, PNG, BMP, TGA и т.п. очень удобно использовать библиотеку PILLOW. Обратите внимание, что если мы хотим загрузить сжатую текстуру (например, формата DXTn), то здесь нам PILLOW уже не поможет - мы должны сделать это сами.

Также очень удобно использовать numpy для представления данных в виде массивов из элементов базовых типов в пкамяти для загрузки в память GPU.

Для С++ я обычно использую Assimp, но тут ситуация оказалась хуже - есть обертка, но она ориентирована на python 2 и требует наличия бинарных файлов библиотеки (например, assimp.dll). Поэтому я решил использовать свой парсер для формата obj и библиотеку pygltflib для загрузке данных в форматах GLTF/GLB.

Все приводимые ниже примеры были проверены на python 3.8 и выше, хотя, скорее всего, будут работать на любом python 3. Установить нужные библиотеки можно при помощи следующей командной строки (и для windows и Linux):

python -m pip install pyopengl glfw pyglm numpy pillow pygltflib

Ниже приводится простейший пример, создающий окно для вывода при помощи OpenGL.

import sys
import glfw
from OpenGL.GL import *

if not glfw.init():
    print ( 'GLFW initialization error' )
    sys.exit ( 1 )

glfw.window_hint ( glfw.CONTEXT_VERSION_MAJOR, 3 )
glfw.window_hint ( glfw.CONTEXT_VERSION_MINOR, 3 )
glfw.window_hint ( glfw.OPENGL_PROFILE, glfw.OPENGL_CORE_PROFILE )
glfw.window_hint ( glfw.OPENGL_FORWARD_COMPAT, glfw.TRUE )
glfw.window_hint ( glfw.RED_BITS,     8 )
glfw.window_hint ( glfw.GREEN_BITS,   8 )
glfw.window_hint ( glfw.BLUE_BITS,    8 )
glfw.window_hint ( glfw.ALPHA_BITS,   8 )
glfw.window_hint ( glfw.DEPTH_BITS,   24 )

window = glfw.create_window ( 800, 600, "Test pyOpenGL window", None, None )
glfw.make_context_current ( window )

glViewport ( 0, 0, 800, 600 )

def display ():
    glClear ( GL_COLOR_BUFFER_BIT )

while not glfw.window_should_close ( window ):
    glfw.poll_events  ()
    display    ()
    glfw.swap_buffers ( window )
    
glfw.terminate ()

Поскольку я привык заворачивать все в классы, то естественно, что и для python и данных библиотек я создал свой небольшой набор вспомогательных классов (pure python, cross-platform). Эти классы очень близки к соответствующим классам на С++ (основное отличие использование класса Window вместо GlutWindow). Ряд примеров из книги я переписал на python, но полного переписывания всех примеров не будет. Я использую свои функции для разбора DDS и загрузки текстур, но пока не все форматы поддерживаются, поддержка новых форматов будет постепенно добавляться.

Базовым классом будет класс Window, инкапсулирующий в себе поддержку окна, в которое будет проходить рендеринг. За собственно сам рендеринг и обработку сообщений оконной системы отвечают методы этого класса. Обратите внимание, что хотя названия методов соответствуют использованным в классе GlutWindow (C++), но входные аргументы слегка отличаются из-за специфики бииблиотеки GLFW. Ниже приводится простейшее приложение с использованием этого класса, полностью эквивалентное приведенному выше примеру.

# 1st example - create empty window
import Window

    # create window with size and caption
w = Window.Window ( 700, 600, "Test window" )

    # run it
w.run()

Для работы с шейдерами я использую класс Program. В отличии от своего аналога на C++ он использует именованные аргументы в конструкторе и может примать на вход как составной glsl-файл (как в примерах к книге), так и отдельные имена файлов (vertex="a.vsh" fragment = "a.fsh"). Кроме того он поддерживает #include внутри glsl-файлов.

Следующий пример выводит разноцветный треугольник. Данные в вершинах задаются через массивы и по ним строятся VAO и VBO.

import numpy
from OpenGL.GL import *
import Window
import Program

vertices = [
    -1.0, -1.0, 0.0,
     0.0,  1.0, 0.0,
     1.0, -1.0, 0.0
]

class   MyWindow ( Window.Window ):
    def __init__ ( self, w, h, t ):
        super().__init__ ( w, h, t, fullScreen = True )

        self.vao = glGenVertexArrays(1)
        self.vbo = glGenBuffers(1)
        glBindVertexArray ( self.vao )
        glBindBuffer      ( GL_ARRAY_BUFFER, self.vbo )
        glBufferData      ( GL_ARRAY_BUFFER, 4 * len(vertices), numpy.array ( vertices, dtype = numpy.float32), GL_STATIC_DRAW )
                                                   # position attribute (loc=0, size=3, float, do not normalize, stride, offset)
        glVertexAttribPointer     ( 0, 3, GL_FLOAT, GL_FALSE, 4*3, ctypes.c_void_p(0) )
        glEnableVertexAttribArray ( 0 )

        self.shader   = Program.Program ( vertex = "simple.vsh", fragment = "simple.fsh" )
        self.shader.use ()

    def redisplay ( self ):
        glClearColor ( 0.2, 0.3, 0.2, 1.0 )
        glClear      ( GL_COLOR_BUFFER_BIT + GL_DEPTH_BUFFER_BIT )
        glEnable     ( GL_DEPTH_TEST )
        glDrawArrays ( GL_TRIANGLES, 0, 6 )

def main():
    win = MyWindow ( 900, 900, "Render triangle" )
    win.run ()

if __name__ == "__main__":
    main()

Для работы с буферами используется класс Buffer, внутри себя использующий numpy для перевода массивов в numpy.array с заданным типом для загрузки в память GPU.

# vb - list of data values
vel = Buffer.Buffer ( GL_SHADER_STORAGE_BUFFER, vb )
vel.bindBase ( GL_SHADER_STORAGE_BUFFER, 1  )

В репозитории с исходным кодом к книге () вы можете найти много примеров на python (в каталоге Code/python-code), ниже я расмотрю только несколько из них. Ниже приводится python-код для вывода объекта с использованием модели освещения Блинна-Фонга.

import math
import glm
from OpenGL.GL import *
import Window
import Program
import Mesh

class   MyWindow ( Window.RotationWindow ):
    def __init__ ( self, w, h, t ):
        super().__init__ ( w, h, t )
        self.eye      = glm.vec3 ( -7, 0, 0 )
        self.lightDir = glm.vec3 ( -1, 1, 1 )
        self.kd       = 0.8
        self.ka       = 0.2
        self.ks       = 0.2
        self.mesh     = Mesh.Mesh.createKnot ( 1, 1, 120, 30 )
        self.shader   = Program.Program ( glsl = "blinn-phong.glsl" )
        self.shader.use ()

    def redisplay ( self ):
        glClearColor ( 0.2, 0.3, 0.2, 1.0 )
        glClear      ( GL_COLOR_BUFFER_BIT + GL_DEPTH_BUFFER_BIT )
        glEnable     ( GL_DEPTH_TEST )

        self.shader.setUniformMat ( "mv",       self.getRotation () )
        self.shader.setUniformMat ( "nm",       self.normalMatrix ( self.getRotation () ) )
        self.mesh.render ()

    def reshape ( self, width, height ):
        super().reshape ( width, height )
        self.shader.setUniformMat   ( "proj",  self.getProjection () )
        self.shader.setUniformVec   ( "eye",   self.eye )
        self.shader.setUniformVec   ( "lightDir", self.lightDir )
        self.shader.setUniformFloat ( "kd", self.kd )
        self.shader.setUniformFloat ( "ks", self.ks )
        self.shader.setUniformFloat ( "ka", self.ka )

    def mouseScroll ( self, dx, dy ):
        self.eye += glm.vec3 ( 0.1 * ( 1 if dy >= 0 else -1 ) )
        self.reshape ( self.width, self.height )

def main():
    win = MyWindow ( 900, 900, "Blinn-Phong shading model" )
    win.run ()

if __name__ == "__main__":
    main ()

Следующий пример использует рендеринг в текстуру и потом накладывает текстуру на выводимый объект. Это полный аналог примера из книги. Для вывода в текстуру используется класс Framebuffer.

import math
import glm
import numpy
from OpenGL.GL import *
import Window
import Program
import Texture
import Mesh
import Framebuffer
import dds

class   MyWindow ( Window.RotationWindow ):
    def __init__ ( self, w, h, t ):
        super().__init__ ( w, h, t )
        self.fb        = Framebuffer.Framebuffer ( 512, 512, depth = True )
        self.fb.bind          ()
        self.fb.attachTexture ( self.fb.createTexture () )
        self.fb.unbind        ()
        self.mesh    = Mesh.Mesh.createKnot ( 1, 4, 120, 30 )
        self.cube    = Mesh.Mesh.createBox ( glm.vec3 ( -1, -1, -1 ), glm.vec3 ( 2, 2, 2 ) )
        self.texture = Texture.Texture ( "../../Textures/Fieldstone.dds" )
        self.shader  = Program.Program ( glsl = "rotate-8.glsl" )
        self.shader.use         ()
        self.shader.setTexture  ( "image", 0 )
        self.eye = glm.vec3  ( 2.5, 2.5, 2.5 )

    def redisplay ( self ):
        self.displayBoxes ()

        glClearColor ( 0.5, 0.5, 0.5, 1.0 )
        glClear      ( GL_COLOR_BUFFER_BIT + GL_DEPTH_BUFFER_BIT )
        glEnable     ( GL_DEPTH_TEST )

        self.shader.use         ()
        self.fb.colorBuffers [0].bind ()
        self.shader.setUniformMat ( "mv",  self.getRotation () )
        self.shader.setUniformMat ( "nm",  self.normalMatrix ( self.getRotation () ) )
        self.shader.setUniformVec ( "eye", self.eye )
        self.cube.render ()
        self.fb.colorBuffers [0].unbind ()

    def reshape ( self, width, height ):
        super().reshape ( width, height )
        self.shader.setUniformMat ( "proj",  self.getProjection () )

    def mouseScroll ( self, dx, dy ):
        self.eye += glm.vec3 ( 0.1 * ( 1 if dy >= 0 else -1 ) )
        self.reshape ( self.width, self.height )

    def displayBoxes ( self ):
        self.fb.bind ()
        self.texture.bind ()
        self.shader.setUniformMat ( "mv",  glm.rotate(glm.mat4(1), self.time (), glm.vec3(0, 0, 1)))
        self.shader.setUniformVec ( "eye", glm.vec3 ( 7, 7, 7 ) )

        glClearColor ( 0, 0, 0, 1 )
        glClear      ( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT )

        self.mesh.render    ()
        self.texture.unbind ()
        self.shader.unbind  ()
        self.fb.unbind      ()

def main():
    win = MyWindow ( 900, 900, "Environmapped knot" )
    win.run ()

if __name__ == "__main__":
    main()
Более сложным является следующий пример - анимация и рендеринг системы частиц. При этом для анимации системы частиц под воздействием двух черных дыр используется вычислительный шейдер (разбор кода для С++ есть в статье о вычислительных шейдерах). Для хранения информации о частицах используется два объекта Buffer - один хранит координаты частиц, а другой - их скорости. Сами эти буфера доступны внутри шейдера как SSBO (Shader Storage Buffer Object), позволяющие напрямую писать и чтать в них из шейдера.

from OpenGL.GL import *
import glm
import Window
import Program
import Buffer

class   MyWindow ( Window.RotationWindow ):
    def __init__ ( self, w, h, t ):
        super().__init__ ( w, h, t )
        self.vel      = None
        self.pos      = None
        self.compute  = Program.Program ( glsl = "particles-compute.glsl" )
        self.render   = Program.Program ( glsl = "particles-render.glsl" )
        self.initParticles ( 100 )

    def redisplay ( self ):
        glClearColor ( 0.2, 0.3, 0.2, 1.0 )
        glClear      ( GL_COLOR_BUFFER_BIT + GL_DEPTH_BUFFER_BIT )
        glEnable     ( GL_DEPTH_TEST )

        self.compute.bind ()
        self.compute.setUniformFloat ( "deltaT", 0.01 )
        glDispatchCompute ( self.numParticles // 1000, 1, 1 )
        glMemoryBarrier   ( GL_SHADER_STORAGE_BARRIER_BIT )

        self.render.use ()
        self.render.setUniformMat ( "mvp",  self.getProjection () * self.getRotation () )
        glBindVertexArray ( self.vao )
        self.pos.bind          ( GL_ARRAY_BUFFER )
        glPointSize       ( 1.0 )
        glDrawArrays      ( GL_POINTS, 0, self.numParticles )

    def mouseScroll ( self, dx, dy ):
        self.eye += glm.vec3 ( 0.5 * ( 1 if dy >= 0 else -1 ) )
        self.reshape ( self.width, self.height )

    def initParticles ( self, num ):
        n                 = num
        self.numParticles = n * n * n
        self.eye          = glm.vec3 ( -0.5, 0.5, 25 )

            # init buffers with particle data
        vb = []
        pb = []
        h  = 2.0 / (n - 1)

        for i in range ( n ):
            for j in range ( n ):
                for k in range ( n ):
                    pb.append ( h * i - 1 )
                    pb.append ( h * j - 1 )
                    pb.append ( h * k - 1 )
                    pb.append ( 1 )
                    vb.append ( 0 )
                    vb.append ( 0 )
                    vb.append ( 0 )
                    vb.append ( 0 )

            # create VBO'a and VAO
        self.vel = Buffer.Buffer ( GL_SHADER_STORAGE_BUFFER, vb )
        self.vel.bindBase ( GL_SHADER_STORAGE_BUFFER, 1  )
        self.pos = Buffer.Buffer ( GL_SHADER_STORAGE_BUFFER, pb )
        self.pos.bindBase ( GL_SHADER_STORAGE_BUFFER, 0  )

            # create and init VAO
        self.vao = glGenVertexArrays ( 1 )
        glBindVertexArray ( self.vao )
        self.pos.bind          ( GL_ARRAY_BUFFER )
        self.render.use        ()

        glVertexAttribPointer     ( 0, 4, GL_FLOAT, GL_FALSE, 16, ctypes.c_void_p(0))
        glEnableVertexAttribArray ( 0 )

        #self.render.setAttrPtr ( "pos", 4, 4*4, ctypes.c_void_p(0) )
        self.render.unbind     ()
        glBindVertexArray      ( 0 )

            # setup blending
        glEnable    ( GL_BLEND )
        glBlendFunc ( GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA )

def main():
    win = MyWindow ( 900, 900, "Compute particles" )
    win.run ()

if __name__ == "__main__":
    main()

В ряде классов (Program, Framebuffer) добавлена поддержка оператора with, позволяющего автоматически вызывать bind/unbind как показано на примере ниже:

with self.framebuffer:
    with self.shader:
        self.mesh.render ()

Все рассмотренные примеры и классы можнонайти на в репозитории к книге на github в каталоге Code/python-code.