python + Qt + OpenGL

First Step

在本教程中,我们将编写一个小的python脚本,该脚本在带有滑块的GUI中渲染立方体以控制其旋转。这将基于其他教程(即教程),但会更详细地介绍过程和OpenGL概念。您可以在此处下载完整脚本。

 

设置

PyQt4

有许多不同的框架可用于在python中创建GUI-内置选项是TkInter,它提供了跨平台Tk GUI工具包的包装,该工具包易学易懂,适用于小型应用程序,但是很流行平台解决方案是Qt

Qt的python端口称为PyQt,最近有两个版本-PyQt4和PyQt5-可以安装在python 2或3中。由于已经存在许多使用PyQt4的教程,而且核心库似乎很多版本之间相同,我们将使用PyQt4。

该软件包可以通过以下方式安装在linux中:

apt-get install python-qt4

如果您使用的是Windows,则与其他许多python软件包一样,您可以pip install在从非官方Windows Python扩展包二进制文件页面下载的python轮上使用PyQt4 。如果您希望改为从源代码构建,请参阅本指南

PyOpenGL的

还有许多不同的库可用于在python中创建3D图形,但是最常见的跨平台解决方案是OpenGL-特别是使用PyOpenGL包装器。您应该可以pip install pyopengl在Linux上轻松安装此程序,或者再次使用此处pip install下载的轮子安装此程序。在Ubuntu 16.04上,我发现我不得不使用”pip”代替

apt-get install python-qt4-gl

PyQt在其QtOpenGL模块中使用与PyOpenGL相同的系统安装,以提供特殊的OpenGL QWidget,该接口可轻松实现接口连接。在下面的更多内容。

OpenGL管道

请注意,我们将在此处使用的OpenGL功能大部分是固定功能管道的一部分,实际上已不推荐使用OpenGL 3.0以后的版本,而是推荐使用可程序化(基于着色器)的管道,该管道利用现代GPU并行性来有效地进行渲染。不幸的是,网络上的大多数教程都使用了不推荐使用的管道,而这恰恰是我使用较旧的模拟器所惯用的管道。稍后我可能会尝试将所有内容都切换到所谓的现代OpenGL,但是到目前为止,这是一篇不错的文章,它以简单的术语解释了它们之间的区别。

Python IDE

在我们深入探讨使用Qt + OpenGL创建简单应用程序的示例之前,您可能想知道使用哪些工具在python中进行开发。关于这一点的文章很多很多,比我提供的要详尽得多,但是我要说的是,可以通过一些工作将出色的Emacs做成一个出色的轻量级IDE(因为涉及Emacs的所有事情都需要),如果您已经将Emacs用于其他所有功能,则它可以很好地集成到您的工作流程中。我将在下面详细说明我的Emacs设置。

如果您要从Matlab过渡到python并寻找类似的东西,Spyder是一个不错的选择,并且可以在一些python发行版中与其他工具一起使用。使用JetBrains的CLion在C / C ++中进行开发后,我很想尝试PyCharm,因为我听说过很棒的事情(不过,除非您是学生,否则它不是免费的)。

适用于Python的Emacs

Emacs生态系统中有许多用于Python开发的出色软件包,但最近我一直热衷于Elpy;您可以在这里找到更多有关它的信息。要使Elpy使用python3而不是python2,请查看此线程以获取说明

Elpy内置了一些很棒的功能,例如自动补全功能,但是您可能希望使用其他软件包来扩展它。要查看当前集成了什么,请打开Emacs并运行M-x elpy-config它,这将为您提供一个小的界面来检查已安装的东西。如果您想进一步进行设置,则可以遵循本指南,例如启用flyspell以便进行更好的语法检查等。

非常重要的是,Elpy使用Flake8进行语法检查,并且可以配置为编辑文件~/.flake8(如果不存在,则可以创建该文件)。例如,Flake8突出显示了许多次要的语法错误,这些错误可能会令人讨厌,因此您可以将其添加到此配置文件中:

[flake8]
max-line-length = 99
max-doc-length = 79
ignore = E2,E302,E41,E303

我们也将python.org建议的行长度设置为99,文档字符串长度设置为79,尽管标准python库使用的限制长度更大。

最后,作为一个补充说明,如果Emacs设置中的任何内容要求您评估“临时”缓冲区中的Lisp表达式,请不要惊慌-请访问此页面以获取有关如何执行此操作的帮助。在Emacs中,可以CTRL-X LEFTARROW从主缓冲区轻松访问“临时”缓冲区。

您好,OpenGL!

既然我们已经介绍了IDE的设置和选择,那么让我们从导入必要的模块并了解它们各自提供的功能开始。

from PyQt4 import QtCore      # core Qt functionality
from PyQt4 import QtGui       # extends QtCore with GUI functionality
from PyQt4 import QtOpenGL    # provides QGLWidget, a special OpenGL QWidget

import OpenGL.GL as gl        # python wrapping of OpenGL
from OpenGL import GLU        # OpenGL Utility Library, extends OpenGL functionality

import sys                    # we'll need this later to run our Qt application

无需将其变成Qt入门教程(因为我们现在真的很想专注于OpenGL集成),任何应用程序的主窗口都由继承自的类定义QtGui.QMainWindow。让我们创建我们的主窗口类,然后为其命名并在初始化程序中调整其大小:

class MainWindow(QtGui.QMainWindow):

    def __init__(self):
	QtGui.QMainWindow.__init__(self)    # call the init for the parent class

	self.resize(300, 300)
	self.setWindowTitle('Hello OpenGL App')


if __name__ == '__main__':

    app = QtGui.QApplication(sys.argv)

    win = MainWindow()
    win.show()

    sys.exit(app.exec_())

如果您到目前为止已运行所有程序,则应打开一个指定大小的Qt窗口,该窗口的名称为“ Hello OpenGL App”,并且可以退出。多么激动人心!但是,我们如何使用OpenGL在该窗口中渲染某些东西呢?那就是QGLWidget进来的地方。

QGLWidget

QGLWidget来绘图是Qt物件专为使用OpenGL容易渲染图形。我们通过子类化QGLWidget并实现三个提供的虚函数来实现此目的,这些虚函数会在必要时由Qt自动调用:

  • initializeGL:调用一次以设置OpenGL渲染上下文,然后再调用调整大小或绘制
  • resizeGL:在创建窗口时以及在调整窗口大小以设置OpenGL视口和投影时调用一次
  • paintGL:在更新小部件以渲染场景时调用

让我们继承QGLWidget的子类,然后开始填充这些功能。

class GLWidget(QtOpenGL.QGLWidget):
    def __init__(self, parent=None):
	self.parent = parent
	QtOpenGL.QGLWidget.__init__(self, parent)

在派生类的初始化程序中,我们调用父GLWidget类的初始化程序。

    def initializeGL(self):
	self.qglClearColor(QtGui.QColor(0, 0, 255))    # initialize the screen to blue
	gl.glEnable(gl.GL_DEPTH_TEST)                  # enable depth testing

在初始化函数中启用了深度测试,以使OpenGL根据深度缓冲区中的值自动确保“片段”正确呈现。

    def resizeGL(self, width, height):
	gl.glViewport(0, 0, width, height)
	gl.glMatrixMode(gl.GL_PROJECTION)
	gl.glLoadIdentity()
	aspect = width / float(height)

	GLU.gluPerspective(45.0, aspect, 1.0, 100.0)
	gl.glMatrixMode(gl.GL_MODELVIEW)

resizeGL功能还有更多设置在发生。首先,glViewport(x,y,width,height)指定用于绘制的窗口部分,通常设置为使用窗口的整个宽度和高度(传递给resizeGL)。

接下来,glMatrixMode(mode)将活动矩阵堆栈设置为投影堆栈,其中包含用于定义查看体积的投影转换。定义此变换,我们首先单位矩阵加载到与所述投影堆栈glLoadIdentity()然后定义观看截gluPerspective(field_of_view, aspect_ratio, z_near, z_far)

视锥细胞

如上图所示,由透视投影创建的观看量称为“平截头体”;垂直视场(FOV)是在第一个参数中直接指定的,而水平FOV是从垂直FOV和纵横比定义的,并且还输入了近和远剪切平面。

还有一个更通用的功能glFrustum可用于创建离轴透视投影(实际上是在引擎盖下gluPerspective调用glFrustrum)。我们可以选择glOrtho使用正交投影来定义查看量。这将创建一个查看体积,该查看体积是一个矩形棱柱,并且不切实际地导致将位于不同深度(z值)的相同高度的对象绘制为相同的大小。我们将在这里坚持透视投影。

正交

最后,我们将矩阵模式设置回GL_MODELVIEW所有后续的相机和模型转换都使用的矩阵模式。通常,GL_PROJECTION除了定义观看量外,我们永远不要使用。

    def paintGL(self):
	gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_DEPTH_BUFFER_BIT)

	# Add rendering code here!

GLWidget要实现的最终虚拟功能是paintGL,这是我们进行所有渲染的地方。现在,我们要做的是使用glClear(bitmask)按位或掩码来告诉OpenGL要清除的缓冲区,进行一些预渲染看家。每次我们清除颜色和深度缓冲区以从干净的石板开始渲染步骤。

让我们使用新完成的GLWidget类,方法是创建一个对象并将其设置为我们的中央小部件,MainWindow如下所示。

class MainWindow(QtGui.QMainWindow):

    def __init__(self):
	QtGui.QMainWindow.__init__(self)    # call the init for the parent class

	self.resize(300, 300)
	self.setWindowTitle('Hello OpenGL App')

	glWidget = GLWidget(self)
	self.setCentralWidget(glWidget)

所有QMainWindow对象都必须具有一个中央窗口小部件,其他窗口小部件可以停靠在该窗口小部件上。运行到目前为止我们编写的所有内容,您将看到一个蓝色的空白窗口,而不是一个黑色的空白窗口,因为我们使用OpenGL在中设置颜色initializeGL

hello_opengl_blue.svg

万岁!设置好之后,让我们做一些更有趣的事情GLWidget

定义几何

立即模式与保留模式

至于渲染简单的几何图形,有两种模式。即时模式包括将绘图命令夹在glBegin和命令之间glEnd,这会导致性能下降,因为GPU必须等待glEnd然后立即渲染。保留模式是现代方法。几何图形是使用所谓的顶点缓冲对象(VBO)定义的,而不是调用每个周期立即进行渲染,这些顶点对象被发送到GPU并存储在GPU上进行渲染。除非需要更新VBO,否则后续渲染不需要CPU和GPU之间的通信,因为VBO数据存储在图形硬件上。

使用VBO定义多维数据集

首先,我们将导入python OpenGL软件包提供的VBO类以及numpy常规数组:

from OpenGL.arrays import vbo
import numpy as np

现在,我们准备定义要绘制的多维数据集的几何形状。让我们在GLWidget类中添加一个新函数,initGeometry它是定义多维数据集的地方。

    def initGeometry(self):
	self.cubeVtxArray = np.array(
	    [[0.0, 0.0, 0.0],
	     [1.0, 0.0, 0.0],
	     [1.0, 1.0, 0.0],
	     [0.0, 1.0, 0.0],
	     [0.0, 0.0, 1.0],
	     [1.0, 0.0, 1.0],
	     [1.0, 1.0, 1.0],
	     [0.0, 1.0, 1.0]])
	self.vertVBO = vbo.VBO(np.reshape(self.cubeVtxArray,
					  (1, -1)).astype(np.float32))
	self.vertVBO.bind()

首先,将以原点为中心的立方体顶点位置定义为2D numpy数组。然后,创建顶点位置VBO vertVBO,小心地重塑了numpy的阵列到一维和铸数组元素np.float32,因为OpenGL的预计浮动(不双打,这是Python的默认值)。最后,绑定VBO以供GPU使用。

	self.cubeClrArray = np.array(
	    [[0.0, 0.0, 0.0],
	     [1.0, 0.0, 0.0],
	     [1.0, 1.0, 0.0],
	     [0.0, 1.0, 0.0],
	     [0.0, 0.0, 1.0],
	     [1.0, 0.0, 1.0],
	     [1.0, 1.0, 1.0],
	     [0.0, 1.0, 1.0 ]])
	self.colorVBO = vbo.VBO(np.reshape(self.cubeClrArray,
					   (1, -1)).astype(np.float32))
	self.colorVBO.bind()

我们创建第二个VBO来存储每个顶点的颜色,该颜色可以任意设置为与顶点位置相同。OpenGL将使用这些顶点颜色以很好的渐变为立方体表面着色。请注意,我们实际上并不需要全新的VBO来显示颜色。取而代之的是,我们可以通过交织行并在渲染时指定跨度(连续顶点或颜色数据值之间的字节数)来将顶点位置和颜色数据存储在同一VBO中。

	self.cubeIdxArray = np.array(
	    [0, 1, 2, 3,
	     3, 2, 6, 7,
	     1, 0, 4, 5,
	     2, 1, 5, 6,
	     0, 3, 7, 4,
	     7, 6, 5, 4 ])

最后,我们指定构成一维数组中六个立方体面中的每个面的顶点。所有这些几何设置都应在中调用initializeGL,我们将其修改为

    def initializeGL(self):
    	self.qglClearColor(QtGui.QColor(0, 0, 255))    # initialize the screen to blue
	gl.glEnable(gl.GL_DEPTH_TEST)                  # enable depth testing

	self.initGeometry()

渲染立方体

要渲染多维数据集,我们需要向该paintGL函数添加代码。将其更新为以下内容:

    def paintGL(self):
	gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_DEPTH_BUFFER_BIT)

	gl.glPushMatrix()    # push the current matrix to the current stack

	gl.glTranslate(0.0, 0.0, -50.0)    # third, translate cube to specified depth
	gl.glScale(20.0, 20.0, 20.0)       # second, scale cube
	gl.glTranslate(-0.5, -0.5, -0.5)   # first, translate cube center to origin

	gl.glEnableClientState(gl.GL_VERTEX_ARRAY)
	gl.glEnableClientState(gl.GL_COLOR_ARRAY)

	gl.glVertexPointer(3, gl.GL_FLOAT, 0, self.vertVBO)
	gl.glColorPointer(3, gl.GL_FLOAT, 0, self.colorVBO)

	gl.glDrawElements(gl.GL_QUADS, len(self.cubeIdxArray), gl.GL_UNSIGNED_INT, self.cubeIdxArray)

	gl.glDisableClientState(gl.GL_VERTEX_ARRAY)
	gl.glDisableClientState(gl.GL_COLOR_ARRAY)

	gl.glPopMatrix()    # restore the previous modelview matrix

让我们一步一步看一下这些新呼叫:

	gl.glPushMatrix()    # push the current matrix to the current stack

	gl.glTranslate(0.0, 0.0, -50.0)    # third, translate cube to specified depth
	gl.glScale(20.0, 20.0, 20.0)       # second, scale cube
	gl.glTranslate(-0.5, -0.5, -0.5)   # first, translate cube center to origin

首先,我们使用glPushMatrix()复制当前的变换矩阵(因为我们没有做其他任何事情,它是恒等式)并将其推入当前的矩阵堆栈中(记得在设置GL_MODELVIEW场景投影后将其设置为)。然后,我们应用一系列变换,以相反的顺序进行构建-因此我们将立方体中心平移到原点(在缩放或旋转之前这是必需的),然后缩放立方体,然后将其平移到较大深度,以便我们可以实际看到它。

但是等等……在哪里渲染立方体?好吧,我们实际上还没有渲染任何东西-我们只是设置要应用于多维数据集的转换,该转换通过以下方式呈现:


	gl.glEnableClientState(gl.GL_VERTEX_ARRAY)
	gl.glEnableClientState(gl.GL_COLOR_ARRAY)

	gl.glVertexPointer(3, gl.GL_FLOAT, 0, self.vertVBO)
	gl.glColorPointer(3, gl.GL_FLOAT, 0, self.colorVBO)

	gl.glDrawElements(gl.GL_QUADS,
			  len(self.cubeIdxArray),
			  gl.GL_UNSIGNED_INT,
			  self.cubeIdxArray)

	gl.glDisableClientState(gl.GL_VERTEX_ARRAY)
	gl.glDisableClientState(gl.GL_COLOR_ARRAY)

	gl.glPopMatrix()    # restore the previous modelview matrix

要渲染多维数据集,我们首先启用顶点和颜色数组,然后将指针设置为我们先前使用创建的顶点位置和颜色数组glVertexPointer(size, type, stride, pointer)glColorPointer(size, type, stride, pointer)以便GPU可以访问数据。

然后glDrawElements(mode, count, type, indices),我们调用,以GL_QUADS使用中指定的面索引来渲染立方体面cubeIdxArray。或者,我们可以直接在顶点VBO中定义这些面并使用它们glDrawArrays,但这需要将重复的顶点发送到GPU。使用glDrawElements与索引列表,在这种情况下更有效。

最后,我们禁用启用的客户端状态(为了安全起见),然后从模型视图堆栈中弹出当前矩阵,将堆栈顶部重置为恒等转换。

渲染循环

将所有内容放在一起会产生……什么都没有!我们已经准备好所有代码,但paintGL从未真正调用过。由我们决定何时/多久渲染一次;最简单的方法是将计时器设置updateGL为回调(可paintGL自动调用),以便定期进行渲染。更新MainWindow类,如下所示:

class MainWindow(QtGui.QMainWindow):

    def __init__(self):
	QtGui.QMainWindow.__init__(self)

	self.resize(300, 300)
	self.setWindowTitle('Hello OpenGL App')

	glWidget = GLWidget(self)
	self.setCentralWidget(glWidget)

	timer = QtCore.QTimer(self)
	timer.setInterval(20)   # period, in milliseconds
	timer.timeout.connect(glWidget.updateGL)
	timer.start()

我们创建了一个QTimer对象,并将其设置为每20毫秒(50 Hz)发出一个信号,并将该信号连接到派生自基类的插槽(函数)。当我们开始添加滑块和其他GUI元素时,我们将更多地利用Qt的信号和插槽。updateGLGLWidget

添加旋转滑块

hello_opengl_cube.svg

现在,我们的应用程序中呈现了一个多维数据集!让我们添加一个滑块来更改其方向,以便实际上可以看到它是三维的。这涉及到修改 MainWindow类,我们在其中设置了GUI结构。请注意,创建更复杂的GUI可能需要QtCreator之类的设计工具(对于C ++应用程序),但是对于简单的GUI,我们可以手动添加元素。

class MainWindow(QtGui.QMainWindow):

    def __init__(self):
        QtGui.QMainWindow.__init__(self)    # call the init for the parent class
        
        self.resize(300, 300)
        self.setWindowTitle('Hello OpenGL App')

        self.glWidget = GLWidget(self)
	self.initGUI()
        
        timer = QtCore.QTimer(self)
        timer.setInterval(20)   # period, in milliseconds
        timer.timeout.connect(self.glWidget.updateGL)
        timer.start()
        
    def initGUI(self):
        central_widget = QtGui.QWidget()
        gui_layout = QtGui.QVBoxLayout()
        central_widget.setLayout(gui_layout)

        self.setCentralWidget(central_widget)

        gui_layout.addWidget(self.glWidget)

        sliderX = QtGui.QSlider(QtCore.Qt.Horizontal)
        sliderX.valueChanged.connect(lambda val: self.glWidget.setRotX(val))

        sliderY = QtGui.QSlider(QtCore.Qt.Horizontal)
        sliderY.valueChanged.connect(lambda val: self.glWidget.setRotY(val))

        sliderZ = QtGui.QSlider(QtCore.Qt.Horizontal)
        sliderZ.valueChanged.connect(lambda val: self.glWidget.setRotZ(val))
        
        gui_layout.addWidget(sliderX)
        gui_layout.addWidget(sliderY)
        gui_layout.addWidget(sliderZ)

我们添加了一个新initGUI函数,该函数将代替原始调用而被设置glWidget为应用程序的中央小部件。该功能封装了所有的GUI布局设置。让我们逐步进行。

    def initGUI(self):
        central_widget = QtGui.QWidget()
        gui_layout = QtGui.QVBoxLayout()
        central_widget.setLayout(gui_layout)

	self.setCentralWidget(central_widget)

我们可以通过以下方式构造Qt GUI:首先定义一个新的中央小部件,为此小部件创建一个布局,该布局定义将如何组织添加到它的小部件(垂直堆叠,因为我们使用QVBoxLayout),然后设置中央小部件的布局。最后,我们将新的小部件设置为MainWindow该类的中央小部件。

        gui_layout.addWidget(self.glWidget)

        sliderX = QtGui.QSlider(QtCore.Qt.Horizontal)
        sliderX.valueChanged.connect(lambda val: self.glWidget.setRotX(val))

        sliderY = QtGui.QSlider(QtCore.Qt.Horizontal)
        sliderY.valueChanged.connect(lambda val: self.glWidget.setRotY(val))

        sliderZ = QtGui.QSlider(QtCore.Qt.Horizontal)
        sliderZ.valueChanged.connect(lambda val: self.glWidget.setRotZ(val))
        
        gui_layout.addWidget(sliderX)
        gui_layout.addWidget(sliderY)
        gui_layout.addWidget(sliderZ)

接下来,我们将小部件添加到中央小部件的布局中,从glWidget顶部开始,然后是QSlider其下方的三个小部件。我们将valueChanged每个滑块的信号连接到相应的插槽函数,该函数捕获滑块值val并设置立方体绕轴的旋转。

我们将旋转角度作为函数的glWidget属性添加initializeGL

    def initializeGL(self):
        self.qglClearColor(QtGui.QColor(0, 0, 255))    # initialize the screen to blue
        gl.glEnable(gl.GL_DEPTH_TEST)                  # enable depth testing

        self.initGeometry()

        self.rotX = 0.0
        self.rotY = 0.0
        self.rotZ = 0.0

使用上述设置器从滑块更新

    def setRotX(self, val):
        self.rotX = val

    def setRotY(self, val):
        self.rotY = val

    def setRotZ(self, val):
        self.rotZ = val

然后更新paintGL渲染代码以使用这些角度来执行立方体围绕局部轴的连续旋转(我们将在以后的文章中进一步介绍旋转,但是格式为axis-angle):

        gl.glTranslate(0.0, 0.0, -50.0)    # third, translate cube to specified depth
        gl.glScale(20.0, 20.0, 20.0)       # second, scale cube
        gl.glRotate(self.rotX, 1.0, 0.0, 0.0)
        gl.glRotate(self.rotY, 0.0, 1.0, 0.0)
        gl.glRotate(self.rotZ, 0.0, 0.0, 1.0)
        gl.glTranslate(-0.5, -0.5, -0.5)   # first, translate cube center to origin

结果是相同的渲染多维数据集,但在其下方具有三个滑块,使我们可以旋转它。您应该看到的东西几乎(但不是完全)完全不同于下面的多维数据集。

cube.gif

想知道为什么我的立方体看起来如此可怕吗?我使用了这个漂亮的工具将屏幕直接记录到GIF中,并且GIF格式具有极其有限的调色板(8位,所以256种颜色),当试图显示带有一系列颜色渐变的多维数据集时,这一点非常明显。我保证你的看起来会更好!

hello_opengl_cube_rotated.svg

您可以在此处下载完整脚本。

标签

发表评论