IRT : un ray tracer interactif - Partie 3

Matthieu Brucher

Troisième partie de la série de tutoriels dédiée au raytracing (voir ici pour la table des matières).

Intégration dans une GUI.

- Titre : IRT : un ray tracer interactif - Partie 3

- Auteur : Matthieu Brucher

- Parution : 20 mai 2009

- Licence : Copyright ® 2009 Matthieu Brucher. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Objectifs

- II. PyQt

- II-A. Lancement de l'application

- II-B. La fenêtre principale

- II-C. La fenêtre OpengL

- II-D. Le thread de calcul

- II-E. Conclusion

- III. wxPython

- IV. Conclusion

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) :

qt_app.py

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.

qt_app.py

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.

qt_app.py

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("|\nv")
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("|\nv")
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.

qt_app.py

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).

qt_app.py

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.

qt_app.py

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().

qt_app.py

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.

qt_app.py

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.

L'application PyQt

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_app.py

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.