I. Objectifs▲
Les objectifs de ce troisième tutoriel sont les suivants :
- Créer un exemple d'application utilisant le raytracer interactif en PyQt
- Présenter un exemple moins complet basé sur wxPython
II. PyQt▲
Mon choix pour la première application en terme de bibliothèque GUI s'est arrêté sur PyQt. Il y a plusieurs raisons à cela. La première est que PyQt est un wrapper sur Qt, qui est sans doute la meilleure bibliothèque C++ actuelle en termes graphiques. Utiliser PyQt ici me permet de me faire une opinion de cette bibliothèque. De plus, l'application finale pour IRT sera une application 100% C++, et avoir un prototype Python qui respecte déjà grosso modo l'architecture finale C++ est une bonne chose.
Le principe général de l'application sera le suivant (plus ou moins commun avec l'application wxPython) :
- Un widget OpenGL qui se chargera de l'affichage
- Un thread calculera continuellement un nouveau rendu
- Dès que le thread de calcul a fini un rendu, une mise à jour de l'affichage sera déclenchée
- Le côté interactif sera réalisé par 8 boutons qui permettront de se déplacer dans la scène et de tourner selon chaque axe.
L'utilisation d'un widget OpenGL pour afficher une image précalculé est dû à l'impossibilité d'utiliser les outils de dessin de Qt sans passer par une conversion. En effet, le raytracer travaille sur un tableau de flottants. Qt travaille lui sur des entiers. Pour éviter de faire la conversion soi-même, on délègue le travail à OpenGL.
II-A. Lancement de l'application▲
Voici la base de l'application (chargement des modules et lancement de l'application) :
import
os
import
sys
import
numpy
import
time
import
math
from
PyQt4.QtCore import
*
from
PyQt4.QtGui import
*
from
PyQt4.QtOpenGL import
*
import
OpenGL.GL as
GL
from
sample import
Sample
def
main
(
):
app =
QApplication
(
sys.argv)
app.setOrganizationName
(
"Matthieu Brucher"
)
app.setOrganizationDomain
(
"matt.eifelle.com"
)
app.setApplicationName
(
"Qt IRT"
)
form =
MainWindow
(
)
form.show
(
)
app.exec_
(
)
if
__name__
==
"__main__"
:
main
(
)
Dans cette application, il doit être possible de tourner la scène autour d'un axe. A cet effet, une fonction dédiée est conçue (puisqu'il faut pouvoir tourner autour de chaque axe de la scène). La fonction est très simple, l'axe de rotation est placé sur l'axe z (après avoir été placé dans l'axe xz) puis replacé dans l'espace original. Ainsi, on se ramène à une rotation dans le plan xy.
def
rotate
(
vector, axis, angle):
"""
Rotates a 3D vector with an axis and an angle
"""
XY =
math.sqrt
(
numpy.sum(
axis[:2
]**
2
))
if
XY ==
0
:
cos_alpha =
1.
sin_alpha =
0.
else
:
cos_alpha =
axis[0
]/
XY
sin_alpha =
axis[1
]/
XY
axisb =
numpy.array
((
XY, 0.
, axis[2
]), dtype =
numpy.float32)
vectorb =
numpy.array
((
vector[0
] *
cos_alpha +
vector[1
] *
sin_alpha, -
vector[0
] *
sin_alpha +
vector[1
] *
cos_alpha, vector[2
]),
dtype =
numpy.float32)
XZ =
math.sqrt
(
numpy.sum(
axisb**
2
))
if
XZ ==
0
:
cos_beta =
1.
sin_beta =
0.
else
:
sin_beta =
axisb[0
]/
XZ
cos_beta =
axisb[2
]/
XZ
vectorc =
numpy.array
((
vectorb[0
] *
cos_beta +
vectorb[2
] *
sin_beta, vectorb[1
], -
vectorb[0
] *
sin_beta +
vectorb[2
] *
cos_beta),
dtype =
numpy.float32)
cos_ =
math.cos
(
angle)
sin_ =
math.sin
(
angle)
vectorc =
numpy.array
((
vectorc[0
] *
cos_ +
vectorc[1
] *
sin_, -
vectorc[0
] *
sin_ +
vectorc[1
] *
cos_, vectorc[2
]),
dtype =
numpy.float32)
vectorb =
numpy.array
((
vectorc[0
] *
cos_beta -
vectorc[2
] *
sin_beta, vectorc[1
], vectorc[0
] *
sin_beta +
vectorc[2
] *
cos_beta),
dtype =
numpy.float32)
vector =
numpy.array
((
vectorb[0
] *
cos_alpha -
vectorb[1
] *
sin_alpha, vectorb[0
] *
sin_alpha +
vectorb[1
] *
cos_alpha, vectorb[2
]),
dtype =
numpy.float32)
return
vector
II-B. La fenêtre principale▲
La fenêtre principale sera constituée de deux objets principaux : la fenêtre OpenGL et la panneau latéral. Ces deux éléments doivent être placés dans un autre widget central. Une méthode dédiée sera chargée de créer le panneau latéral et créera les connections entre les clicks sur les boutons des panneaux latéraux et leur signal associé.
Une méthode supplémentaire consiste à mettre à jour dans la barre de status le nombre courant de FPS.
class
MainWindow
(
QMainWindow):
def
__init__
(
self, parent=
None
):
super(
MainWindow, self).__init__
(
parent)
bigWidget =
QWidget
(
)
self.glWidget =
IRTGLWidget
(
bigWidget, self)
sidepanel =
self.createSidePanel
(
)
layout =
QHBoxLayout
(
)
layout.addWidget
(
self.glWidget, Qt.AlignHCenter |
Qt.AlignVCenter)
layout.addWidget
(
sidepanel, Qt.AlignHCenter |
Qt.AlignVCenter)
bigWidget.setLayout
(
layout)
self.setCentralWidget
(
bigWidget)
self.setWindowTitle
(
self.tr
(
"Qt IRT"
))
self.statusFPS =
QLabel
(
)
self.statusBar
(
).addPermanentWidget
(
self.statusFPS)
self.dirty =
False
self.connect
(
self, SIGNAL
(
"updateFPS"
), self.updateStatusFPS)
def
updateStatusFPS
(
self, fps):
"""
Affiche les FPS dans la barre de status
"""
self.statusFPS.setText
(
"fps:
%f
"
%
fps)
def
rotateUp
(
self):
"""
Rotation vers le haut (autour de l'axe x)
"""
self.glWidget.thread.commands.append
(
self.glWidget.thread.rotateUp)
def
rotateDown
(
self):
"""
Rotation vers le bas (autour de l'axe x)
"""
self.glWidget.thread.commands.append
(
self.glWidget.thread.rotateDown)
def
rotateLeft
(
self):
"""
Rotation vers la gauche (autour de l'axe y)
"""
self.glWidget.thread.commands.append
(
self.glWidget.thread.rotateLeft)
def
rotateRight
(
self):
"""
Rotation vers la droite (autour de l'axe y)
"""
self.glWidget.thread.commands.append
(
self.glWidget.thread.rotateRight)
def
goIn
(
self):
"""
Rentrer dans l'écran
"""
self.glWidget.thread.commands.append
(
self.glWidget.thread.goIn)
def
goBack
(
self):
"""
Sortir de l'écran
"""
self.glWidget.thread.commands.append
(
self.glWidget.thread.goBack)
def
inclinateLeft
(
self):
"""
Tourner vers la gauche (tourner autour de z)
"""
self.glWidget.thread.commands.append
(
self.glWidget.thread.inclinateLeft)
def
inclinateRight
(
self):
"""
Tourner vers la droite (tourner autour de z)
"""
self.glWidget.thread.commands.append
(
self.glWidget.thread.inclinateRight)
def
createSidePanel
(
self):
sidepanel =
QFrame
(
)
layout_panel =
QGridLayout
(
sidepanel)
upbutton =
QPushButton
(
)
upbutton.setText
(
"^
\n
|"
)
layout_panel.addWidget
(
upbutton, 0
, 1
)
QObject.connect
(
upbutton, SIGNAL
(
"clicked()"
), self.rotateUp)
downbutton =
QPushButton
(
)
downbutton.setText
(
"|
\n
v"
)
layout_panel.addWidget
(
downbutton, 2
, 1
)
QObject.connect
(
downbutton, SIGNAL
(
"clicked()"
), self.rotateDown)
leftbutton =
QPushButton
(
)
leftbutton.setText
(
"<-"
)
layout_panel.addWidget
(
leftbutton, 1
, 0
)
QObject.connect
(
leftbutton, SIGNAL
(
"clicked()"
), self.rotateLeft)
rightbutton =
QPushButton
(
)
rightbutton.setText
(
"->"
)
layout_panel.addWidget
(
rightbutton, 1
, 2
)
QObject.connect
(
rightbutton, SIGNAL
(
"clicked()"
), self.rotateRight)
label =
QLabel
(
)
label.setText
(
"Rotation"
)
layout_panel.addWidget
(
label, 1
, 1
, Qt.AlignJustify)
mupbutton =
QPushButton
(
)
mupbutton.setText
(
"^
\n
|"
)
layout_panel.addWidget
(
mupbutton, 3
, 1
)
QObject.connect
(
mupbutton, SIGNAL
(
"clicked()"
), self.goIn)
mdownbutton =
QPushButton
(
)
mdownbutton.setText
(
"|
\n
v"
)
layout_panel.addWidget
(
mdownbutton, 5
, 1
)
QObject.connect
(
mdownbutton, SIGNAL
(
"clicked()"
), self.goBack)
ileftbutton =
QPushButton
(
)
ileftbutton.setText
(
"<--"
)
layout_panel.addWidget
(
ileftbutton, 4
, 0
)
QObject.connect
(
ileftbutton, SIGNAL
(
"clicked()"
), self.inclinateLeft)
irightbutton =
QPushButton
(
)
irightbutton.setText
(
"-->"
)
layout_panel.addWidget
(
irightbutton, 4
, 2
)
QObject.connect
(
irightbutton, SIGNAL
(
"clicked()"
), self.inclinateRight)
label =
QLabel
(
)
label.setText
(
"Move/Inclination"
)
layout_panel.addWidget
(
label, 4
, 1
, Qt.AlignJustify)
return
sidepanel
Les méthodes appelées par les clicks sur le panneau latéral ajoutent à une liste de commandes une nouvelle commande à exécuter sous la forme du nom d'une fonction. C'est le thread de calcul qui sera chargé de vider cette liste. Comme il s'agit de code purement Python, il est possible d'ajouter ou d'ôter un élément à une liste sans verrou d'accès.
II-C. La fenêtre OpengL▲
Lors de la création de la fenêtre, le thread de calcul est créé et exécuté. La taille de la fenêtre est de 640x480 à l'origine, mais il est possible de la changer (et le changement sera propagé au thread de calcul). Un signal est aussi créé pour relancer le dessin.
class
IRTGLWidget
(
QGLWidget):
"""
La fenêtre OpenGL avec l'image IRT
"""
def
__init__
(
self, parent, mainWindow):
super(
QGLWidget, self).__init__
(
parent)
self.setFixedSize
(
640
, 480
)
self.thread =
IRTThread
(
self, 640
, 480
, mainWindow)
QObject.connect
(
self, SIGNAL
(
"updateView"
), self.updateGL)
self.thread.start
(
)
def
resizeGL
(
self, width, height):
print
"Resizing the scene (
%d
,
%d
)"
%
(
width, height)
self.setFixedSize
(
width, height)
self.thread.emit
(
SIGNAL
(
"resize"
), width, height)
Le dessin effectif est géré dans la méthode paintGL() appelée par Qt. Pour éviter tout problème, en fait le thread de calcul propose deux images à afficher. Pendant que l'une est dessinnée, l'autre peut être affichée. En Python, il est facile de cacher cela en faisant une propriété (ici screen) qui retournera toujours le bon écran. Mais il est aussi nécessaire de verrouiller l'accès à l'écran afin que le thread de calcul ne commence pas à réécrire sur l'écran actuel (Cela peut se produire facilement. Le premier écran est disponible et le thread de calcul travaille sur le deuxième. OpenGL commence à tracer le premier écran. En même temps, le thread de calcul finit le deuxième écran et commence à écrire sur le premier qui est en cours d'affichage).
def
paintGL
(
self):
GL.glRasterPos
(-
1
,-
1
)
try
:
self.thread.lock.lockForRead
(
)
GL.glDrawPixels
(
self.thread.screen.shape[-
2
], self.thread.screen.shape[-
1
], GL.GL_RGB, GL.GL_FLOAT, self.thread.screen)
finally
:
self.thread.lock.unlock
(
)
II-D. Le thread de calcul▲
L'initialisation du thread de calcul est somme toute aisée. On sauvegarde la variable de la fenêtre principale pour avoir accès au signal de mise à jour de la barre de status et la variable de la fenêtre OpenGL pour déclencher sa mise à jour. Puis on crée la scène par défaut, les deux écrans ainsi que le verrou associé et une vraible indiquant l'écran en cours de tracé.
Ensuite, deux listes sont créées. La première contiendra le temps écoulé pour créer les 10 derniers rendus. La seconde sera la liste des commandes interactives à gérer par le thread. Enfin, l'origine, la direction ainsi que l'orientation doivent être déterminés (c'est eux qui seront modifiés par les commandes inetractives) et donnés au raytracer.
class
IRTThread
(
QThread):
def
__init__
(
self, parent, width, height, mainWindow):
super(
IRTThread, self).__init__
(
parent)
self.mainWindow =
mainWindow
self.glWidget =
parent
self.sample =
Sample
(
width, height)
self.screens =
[numpy.zeros
((
3
, width, height), dtype =
numpy.float32),numpy.zeros
((
3
, width, height), dtype =
numpy.float32)]
self.lock =
QReadWriteLock
(
)
self.currentScreen =
0
self.pastFPS =
[]
self.commands =
[]
self.origin =
numpy.array
((
.0
, .0
, 0.
), dtype=
numpy.float32)
self.direction =
numpy.array
((
.0
, .0
, 40.
), dtype=
numpy.float32)
self.orientation =
numpy.array
((
.0
, 1.
, 0.
), dtype=
numpy.float32)
self.sample.raytracer.setViewer
(
self.origin, self.direction)
self.sample.raytracer.setOrientation
(
self.orientation)
def
resize
(
self, width, height):
self.screens =
[numpy.zeros
((
3
, width, height), dtype =
numpy.float32),numpy.zeros
((
3
, width, height), dtype =
numpy.float32)]
self.sample.setResolution
(
width, height)
def
get_screen
(
self):
return
self.screens[self.currentScreen -
1
]
screen =
property(
get_screen)
Le calcul proprement dit s'effectue dans la méthode paint(). L'appel au raytracer est chronométré, puis le changement d'écran est effectué. Par al suite, le nombre de FPS moyenné sur les 10 derniers échantillons est réalisé puis signalé à la fenêtre principale. Dans le même temps, la fenêtre OpenGL est elle aussi rafraîchie. Cette fonction est appelée par la méthode run() de manière continue. Si des commandes interactives sont à réaliser, elles sont exécutées avant le prochain appel à paint().
def
paint
(
self):
t =
time.clock
(
)
self.sample
(
self.screens[self.currentScreen])
t =
time.clock
(
) -
t
try
:
self.lock.lockForWrite
(
)
self.currentScreen =
1
if
self.currentScreen ==
0
else
0
finally
:
self.lock.unlock
(
)
self.pastFPS.append
(
t)
if
len(
self.pastFPS) >
10
:
self.pastFPS =
self.pastFPS[-
10
:]
self.mainWindow.emit
(
SIGNAL
(
"updateFPS"
), 10.
/
sum(
self.pastFPS))
self.glWidget.emit
(
SIGNAL
(
"updateView"
))
def
run
(
self):
while
True
:
self.paint
(
)
while
self.commands:
command =
self.commands.pop
(
)
command
(
)
Ces méthodes sont les différentes commandes réalisables.
def
rotateUp
(
self):
"""
Rotation vers le haut (autour de l'axe x)
"""
self.orientation =
rotate
(
self.orientation, numpy.cross
(
self.orientation, self.direction), -
math.pi/
180
)
self.direction =
rotate
(
self.direction, numpy.cross
(
self.orientation, self.direction), -
math.pi/
180
)
self.sample.raytracer.setViewer
(
self.origin, self.direction)
self.sample.raytracer.setOrientation
(
self.orientation)
def
rotateDown
(
self):
"""
Rotation vers le bas (autour de l'axe x)
"""
self.orientation =
rotate
(
self.orientation, numpy.cross
(
self.orientation, self.direction), math.pi/
180
)
self.direction =
rotate
(
self.direction, numpy.cross
(
self.orientation, self.direction), math.pi/
180
)
self.sample.raytracer.setViewer
(
self.origin, self.direction)
self.sample.raytracer.setOrientation
(
self.orientation)
def
rotateLeft
(
self):
"""
Rotation vers la gauche (autour de l'axe y)
"""
self.direction =
rotate
(
self.direction, self.orientation, -
math.pi/
180
)
self.sample.raytracer.setViewer
(
self.origin, self.direction)
def
rotateRight
(
self):
"""
Rotation vers la droite (autour de l'axe y)
"""
self.direction =
rotate
(
self.direction, self.orientation, math.pi/
180
)
self.sample.raytracer.setViewer
(
self.origin, self.direction)
def
goIn
(
self):
"""
Rentrer dans l'écran
"""
self.origin +=
self.direction *
.1
self.sample.raytracer.setViewer
(
self.origin, self.direction)
def
goBack
(
self):
"""
Sortir de l'écran
"""
self.origin -=
self.direction *
.1
self.sample.raytracer.setViewer
(
self.origin, self.direction)
def
inclinateLeft
(
self):
"""
Tourner vers la gauche (tourner autour de z)
"""
self.orientation =
rotate
(
self.orientation, self.direction, -
math.pi/
180
)
self.sample.raytracer.setOrientation
(
self.orientation)
def
inclinateRight
(
self):
"""
Tourner vers la droite (tourner autour de z)
"""
self.orientation =
rotate
(
self.orientation, self.direction, math.pi/
180
)
self.sample.raytracer.setOrientation
(
self.orientation)
II-E. Conclusion▲
Cette application a été conçue de manière naïve et simpliste. Elle permet tout de même d'avoir une idée de la vitesse attendue en rendu continu. Elle permet aussi de prototyper une application plus complexe qui permettra de charger une scène puis d'effectuer le rendu interactif.
Afin de permettre l'interactivité de l'interface graphique, le wrapper Python relâche le verrou de l'interpréteur (GIL). Sans cela, le thread de rendu effectuerait son calcul sans permettre à l'interface de réagir aux clicks de la souris ou tout simplement à n'importe quelle interaction classique.
III. wxPython▲
Pourquoi cet exemple est-il plus petit que celui sur PyQt ? En réalité, l'amélioration de l'application est laissée à la charge du lecteur. Cela fait un bon exercice de programmation GUI (avec la meilleure bibliothèque Python, à mon sens) et multi-thread.
En réalité, le code de calcul est identique à l'exemple PyQt, sauf que lorsque le calcul est fini, il n'est plus possible d'utiliser les signaux. A la place, wxPython propose d'ajouter un événément dans le thread principal (celui gérant l'interface graphique). C'est cette solution qui a été utilisée, grpace à la fonction CallAfter().
wx.CallAfter
(
self.mainWindow.updateStatusFPS, 10.
/
sum(
self.pastFPS))
wx.CallAfter
(
self.glWidget.Refresh)
IV. Conclusion▲
Avant de rentrer dans le détail des optimisations possibles, il était nécessaire de pouvoir créer une application graphique simple permettant de se déplacer dans une scène statique. Il est facile de pouvoir modifier le chargement d'une scène, cela n'a pas été fait et est laissé comme exercice à la charge du lecteur (tout comme l'amélioration de l'exemple wxPython).
La suite sera consacrée normalement au suréchantillonage et à tout ce qui peut être utile à ce niveau pour avoir une image réelle, puis les spécificités des scènes dynamiques et du rendu temps réel seront enfin abordés.