Lancer de rayons - Les 12 travaux

Avant propos - Le code

Plusieurs classes sont fournies afin que vous n'ayez à vous concentrer que sur les parties graphiques du TP. La plupart des classes auront à être complétées, d'autres ne seront utilisées complètement qu'à la fin du TP (Material). Parcourez le code rapidement pour en comprendre la structure:

Partie I - Les sphères

Sphere Dans un premier temps, notre scène 3D ne sera composée que de sphères. Celles-ci sont définies par leur position, leur rayon et leur couleur et définies dans la classe Sphere.

Cette classe dérive de la classe abstraite Object qui regroupe les méthodes communes à tous les types d'objets de la scène. En particulier chaque objet possède un repère (Frame) qui définit sa position et son orientation dans l'espace ainsi qu'un matériau (Material) représentant un matériau complexe, dont on utilisera pour le moment que la diffuseColor().

Les fichiers sphere.{h|cpp} contiennent une implémentation de la classe Sphere. Complétez la méthode draw() de cette classe qui dessinera la sphère en OpenGL.

Modifiez la matrice de GL_MODELVIEW via un glMultMatrixd(frame().matrix()) pour vous placer dans le repère local de l'objet. Pensez à sauvegarder/restaurer la matrice avant/après votre affichage (glPushMatrix()) pour revenir au repère du monde. Changez ensuite la couleur en utilisant un glColor3fv(material().diffuseColor()). Dessiner enfin la sphère en utilisant la méthode gluSphere() dont vous chercherez la documentation.

Testez ces méthodes en instanciant des sphères dans le viewer, et en remplaçant le dessin de la spirale par des appels aux méthodes draw() des sphères définies. Il faut temporairement ajouter un #include "sphere.h" pour cela.

Partie II - Gestion de la scène

La scène 3D sera représentée par une classe Scene qui contiendra principalement une liste d'Object, sur laquelle elle appliquera des méthodes génériques définies dans Object. Ces méthodes seront déclarées virtuelles pures dans Object et auront donc à être ré-implémentées et spécifiées par les différentes classes d'objets.

Le viewer aura lui un pointeur vers cette Scene. Cette séparation des données permet par exemple d'avoir deux viewers de la même Scene, ou d'appeler les méthodes de la Scene via un autre programme, qui pourra par exemple ne pas faire d'affichage et être paramétré via une ligne de commande (batch processing).

Lancez la commande assistant pour afficher l'aide de Qt. Dans l'index, regarder comment définir et utiliser un QPtrList<Object> (QList avec Qt4) pour définir une liste d'objets. Cette liste sera déclarée privée et seule la Scene pourra la parcourir.

Définissez ensuite la méthode addObject(Oject* o) qui permet d'ajouter un objet à cette liste. Faites une méthode draw() qui parcourt le tableau et appelle la méthode draw de chaque objet de la scène.

Complétez les deux méthodes radius et center permettant d'avoir une estimation du centre et du rayon de la scène. Pas la peine d'être exact, ça ne sert qu'au viewer pour afficher l'intégralité de la scène chargée.

Utiliser pour cela la méthode boundingRadius() de Object, qui donne une estimation du rayon d'un objet. La méthode position() de Frame vous permet d'avoir la position de l'objet. Les méthodes de la classe Vec (norme, somme, division...) vous seront alors pratiques pour calculer ces valeurs.

Partie III - Chargement de la scène 3D

Le format XML

Les scènes seront décrites par des fichiers au format XML. Ce type de fichiers permet de représenter une très grande variété de données dans un formalisme identique et rigoureux. Il permet également de facilement extraire et transformer cette information (voir XSLT). Un autre avantage de cette structure est qu'on peut ignorer certaines parties du fichier, le reste restant syntaxiquement valide. C'est ce que nous ferons dans ce TP, où nous n'utiliserons au début qu'une sous-partie des informations des fichiers de scène.

La syntaxe des fichiers se comprend aisément en lisant un exemple minimaliste:
<?xml version="1.0" encoding="UTF-8"?>
<Scene>
 <Sphere radius="0.4">
  <Material>
   <DiffuseColor red="0.1" green="0.1" blue="0.9" />
  </Material>
  <Frame>
   <position x="0.1" y="0.2" z="0.0" />
   <orientation q0="0.0" q1="0.0" q2="0.0" q3="1.0" />
  </Frame>
 </Sphere>
</Scene>
La méthode loadScene() du viewer ouvre une fenêtre pour demander un nom de fichier (remarquez que Qt permet de le faire en une ligne de code). Elle délèguera ensuite le chargement du fichier à la Scene via une méthode loadFromFile(const QString& filename) que vous implémenterez dans Scene.

Dans notre structure de fichier, les différents objets de la scène sont définis les uns à la suite des autres, comme fils du noeud racine Scene. À terme la scène pourra contenir des objets de différent type (lumières, cylindre, plans, ...) et leur tagName() permettra de savoir quel type d'objet créer à partir du fichier. Nous nous restreignons pour le moment aux sphères.

Chargement d'une sphère

Trois sphères Il existe deux méthodes principales pour lire un fichier XML: DOM et SAX. La première transfère l'intégralité des informations dans une structure arborescente qu'il suffit de parcourir ensuite, tandis que la seconde fait appel à des méthodes callback lors du parcours du fichier. La première est très simple tandis que la seconde est rapide et pratique pour les fichiers volumineux.

Nous allons utiliser l'interface DOM fournie par Qt pour lire le fichier de scéne. Regardez dans assistant la documentation de QDomDocument. Regardez en particulier le code donné en exemple qui affiche les noms des noeuds fils du noeud racine. Copier ce code pour parcourir les différents fils du noeud racine dans loadFromFile(). Affichez les noms des tagName() rencontrés pour vérifier.

Lorsque le tagName() est Sphere, on créera une Sphere, on l'initialisera via la méthode initFromDomElement() de la classe Sphere, et on la placera dans la liste des objets de la scène. La plupart des classes possèdent une méthode initFromDomElement() qui permet de les initialiser à partir d'un QDomElement.

C'est en particulier le cas pour les Object. Regarder le code de Object::initFromDomElement() (et celui d'autres classes) pour mieux en comprendre le fonctionnement. Puisqu'une Sphere est un Object, on peut décider que le code XML représentant une Sphere est celui d'un Object auquel on a ajouté des informations en XML. C'est tout à fait cohérent avec la notion d'héritage. Ceci permet à un Objet de s'initialiser correctement à partir du QDomElement correspondant à une Sphere, en n'utilisant que les champs (tagName()) reconnus.

La première chose à faire dans Sphere::initFromDomElement() est donc un simple appel à Objet::initFromDomElement(), qui initialise le repère et le matériau. Il reste ensuite à initialiser le rayon de la sphère. Cette valeur est indiquée comme attribut du noeud Sphere passé en paramètre. Les fonctions hasAttribute() et attribute de la classe QDomElement (cf assistant) permettent de récupérer ces valeurs. Le résultat est une chaine de caractères QString qui peut être convertie à l'aide de la méthode statique toFloat() de QString.

Pour la suite, pensez à rendre votre code de lecture XML robuste en gérant les problèmes de fichiers ne respectant pas la syntaxe (ce qui arrivera lorsque vous les modifierez à la main). Détectez les attributs et fils manquants, en arrêtant avec un message d'erreur précis ou en donnant des valeurs par défaut. La méthode Scene::loadFromFile() sera complétée sur le même principe au fur et à mesure que la définition de notre scène se complexifiera.

Le viewer est maintenant capable d'afficher la scène via la méthode draw() de Scene. Testez votre code sur le fichier troisSpheres.scn, puis sur des variantes de ce fichier pour vérifier que tous les paramètres sont correctement initialisés.

Partie IV - Premier rayon

Rayon Nous allons lancer nos premiers rayons dans la scène. Le principe est simple (voir les diapos de cours du MIT) : pour chaque pixel, lancer un rayon depuis l'oeil vers la scène. Chercher quel objet est le premier intersecté par ce rayon et donner au pixel la couleur correspondante.

Un rayon est défini par un point de départ et une direction, que l'on normera. La classe Ray représente un tel rayon (à l'aide de deux Vec). Ajoutez-lui une méthode draw() permettant de l'afficher en OpenGL (un simple GL_LINES).

Pour tester notre algorithme de lancer de rayon, on souhaite pouvoir lancer facilement un rayon dans la scène et suivre son trajet. Lorsqu'on clique avec le bouton gauche de la souris en maintenant la touche Shift enfoncée, le QGLViewer appelle alors la méthode select(const QPoint& point) qui permet normalement de sélectionner un objet de la scène (voir la documentation de select()). Nous allons surcharger cette méthode afin qu'elle lance un rayon partant de la position courante de la caméra et passant par le pixel choisi.

La camera() associée à tout QGLViewer possède justement une méthode convertClickToLine() qui donne le bon résultat. Ajoutez un membre Ray à votre viewer, initialisez-le dans select et affichez-le dans draw(). Testez votre méthode: crééz un rayon puis déplacez la caméra pour vérifier qu'il est bien dessiné au bon endroit. Désactivez le GL_LIGHTING pour cet affichage et les suivants où la normale n'est pas définie (regardez ce qui se passe sinon).

L'affichage de la position de la caméra se fait en sauvant la position (Alt+F1 à F12) d'où l'on lance le rayon, puis en activant l'affichage des positions de caméras avec C (voir l'onglet Keyboard de l'aide, obtenue par H).

Partie V - Intersection rayon-scène

Intersection L'étape suivante consiste à trouver l'intersection (éventuelle) la plus proche du rayon avec les objets de la scène. On va pour cela utiliser une méthode bool intersect(const Ray& ray, Hit& hit) const dans la classe Object. Elle renvera vrai ssi une intersection a été trouvée avec le rayon. La classe Scene possèdera la même méthode qui se contentera d'appeler successivement cette méthode sur tous les objets de la scène.

Le paramètre hit de la méthode précédente va contenir toutes les informations d'intersection nécessaires pour la suite. La classe Hit stocke en particulier un "temps", correspondant à la distance de l'intersection à l'origine du rayon (d'où l'intérêt d'avoir normalisé la direction du rayon). Ce temps sera toujours positif (sinon cela représente une intersection avec un objet situé derrière l'origine du rayon) et il sera mis à jour par chaque objet qui intersecte le rayon à un temps inférieur au temps courant. Il est initialisé à une très grande valeur correspondant à une intersection à l'infini.

En plus du temps, Hit stocke la position dans le repère du monde du point d'intersection correspondant au temps courant. Les informations stockées dans la classe Hit n'ont évidemment de signification que si intersect() renvoie vrai.

Le Material associé à un Hit est le matériau de l'objet qui a provoqué cette intersection. Chaque objet teste donc s'il intersecte le rayon, et si c'est à un temps inférieur à celui actuellement stocké dans le Hit, le hit est modifié en conséquence.

Visualisez le résultat de votre code en appelant intersect lorsque vous cliquez sur un pixel avec Shift (i.e. dans la méthode select). Si une intersection est trouvée, affichez le point d'intersection (utilisez GL_POINTS et glPointSize()). Vérifiez qu'il est bien toujours au bon endroit, en particulier dans les cas complexes (plusieurs sphères alignées intersectant le rayon). Il se peut que le point ne s'affiche pas à cause de problèmes de précision numérique : il est sur la sphère, ce qui n'est pas vraiment défini le Z buffer risque de le considérer comme dedans et donc caché. Réglez ce problème, par exemple en décalant très légèrement le point ou en désactivant le test de profondeur.

Partie VI - Définition d'une caméra

Nous allons maintenant ajouter une caméra à notre scène. Cette caméra sera fournie dans le ficher .scn de définition de scène, comme un objet 3D. Voici un exemple de XML définissant une telle caméra:
<Camera fieldOfView="0.7854" xResolution="400" yResolution="400">
 <Frame>
  <position x="1.0" y="-1.0" z="0.5" />
  <orientation q0="0.0" q1="0.0" q2="0.0" q3="1.0" />
 </Frame>
</Camera>

Chargement

La classe Camera définit un tel objet et fournit une méthode initFromDOMElement(). Modifiez le loadFromFile de la scène pour prendre en compte les objets de type Camera.

Attention, cette Camera n'a pas de rapport avec la caméra qui affiche la scène dans le QGLViewer, et qui permet de visualiser et de vérifier le bon déroulement de l'algorithme. Néanmoins la méthode initFromScene(), appelée au chargement de la scène, fait correspondre la première position clef de la caméra QGLViewer avec celle définie dans la scène. Appuyez sur F1 pour faire correspondre les deux caméras, ce qui vous permet d'avoir une représentation OpenGL de la scène qui va être rendue (faire CTRL+S pour sauver une telle image, pour comparer).

Affichage de la caméra

Camera Ecrivez la méthode draw(float radius) const de la classe Camera et appelez-la dans le draw du viewer. Cette méthode devra afficher le frustrum (pyramide) correspondant à la caméra ainsi qu'une grille représentant les pixels. Le paramètre radius permet d'adapter la taille de l'affichage à celui de la scène (utilisez un glScalef()).

Le fieldOfView() de la caméra est donné en radians et correspond à l'ouverture verticale totale (comme dans gluPerspective()). L'ouverture horizontale est déduite du raport xResolution/yResolution pour obtenir des pixels carrés.

Le plus simple pour dessiner est de se placer dans le repère de la caméra (avec un simple glMultMatrixd(frame().matrix())). L'axe des Z négatifs est alors le long de la direction de vue (convention OpenGL), les axes X et Y étant horizontaux et verticaux.

Notez l'ajout d'un plan semi-transparent pour mieux visualiser le plan de l'écran.

Affichage des rayons

Rayons L'étape suivante consiste à créer des rayons passant par le centre de chaque pixel. Les coordonnées de ces rayons doivent être exprimées dans le repère du monde, où se font tous les calculs. Dans la classe Camera, la méthode Ray getRay(float x, float y) const doit créér un rayon passant par le pixel (x,y). (0,0) correspond au pixel supérieur gauche de l'image, (xResolution()-1, yResolution()-1) à celui en bas à droite. Vous verrez l'intérêt des float pour les coordonnées par la suite.

La classe Frame possède toutes les méthodes permettant de convertir une position ou une direction exprimée dans le repère du Frame vers le repère du monde. La méthode position() renvoie en particulier la position de l'origine du repère dans le repère monde. Comme d'habitude, vérifiez vos résultats en affichant l'ensemble des rayons. Vérifiez en particulier que les rayons passent bien par les centres des pixels, et que celui correspondant à (0,0) est bien en haut à gauche.

Partie VII - Première image

Premiere Ajouter un attribut backgroundColor de type Color à la classe Scene. Chargez-le depuis le fichier de scène s'il y est présent. Il représentera la couleur de fond de la scène et donc des images.

Les images seront générées par un objet de la classe RayTracer. C'est le viewer qui possède cet objet, et il est initialisé pour avoir un pointeur sur la scène (voir le init() de viewer.cpp).

La méthode renderImage() du RayTracer parcourt tous les pixels de l'image, calcule leur couleur et la stocke avec un setPixel() (voir la documentation de QImage). Notez que cette méthode peut directement prendre une Color en paramètre grâce à l'opérateur QRgb de cette classe.

Pour calculer la couleur du pixel, renderImage utilise la méthode rayColor. Celle-ci se contente pour le moment d'utiliser la méthode intersect de la Scene, en renvoyant la diffuseColor de l'objet intersecté (ou la couleur de fond de la scène s'il n'y a pas d'intersection).

Appuyer sur S pour lancer les rayons et sauvegarder l'image du résultat. Shift+S fait de même, mais depuis le point de vue courant de la caméra QGLViewer au lieu de celle de la scène. C'est pratique pour chercher un point de vue intéressant et faire une image depuis ce point de vue.

L'image est ici agrandie quatre fois pour voir les pixels. Vous pourrez par la suite ajouter un QProgressDialog à renderImage pour voir la progression des calculs longs.

Partie VIII - Éclairage

Normales

Normale Lors de l'intersection d'un rayon avec un objet (les sphères dans notre cas), on va désormais également stocker dans le Hit la normale à la surface au point d'intersection. Ajoutez ce code (dans Hit ainsi que dans la méthode intersect). Pour le tester, vous pouvez afficher une normale au point d'intersection (lorsque vous lancez un rayon avec Shift+Click).

Vous pouvez également affecter à chaque pixel une couleur représentant sa normale (x devient rouge, y vert et z bleu). Attention, la classe Color attend des valeurs comprises entre 0 et 1. Normales fausse couleurs

Lampes

Il faut ensuite définir dans notre scène une ou plusieurs lampes qui l'éclaireront. On considère trois types de sources : ambiantes, directionnelles et ponctuelles. On négligera l'atténuation de leur intensité avec la distance à la source. La classe abstraite Light est dérivée en trois classes : AmbientLight, DirectionalLight et PointLight représentant ces trois types de sources.

Ajoutez un vecteur de pointeurs sur Light dans la scène et initialisez-le d'après le fichier de scène. Selon le tagName() rencontré, il faudra créer un objet de la classe adéquate. Vous pouvez utiliser la méthode draw() de Light pour afficher (sans fioritures) les lampes de la scène pour vérifier votre chargement.

Calculs d'éclairage

Diffus Une AmbientLight représente la couleur cambient d'une lampe virtuelle qui éclaire toute la scène de façon uniforme. Cette lampe, également présente en OpenGL, n'a pas de signification physique et sert à "déboucher" les zones sombres.

La couleur d'un objet est alors le produit de sa couleur diffuse multipliée par la couleur ambiante:
    cpixel  =  cambiente * cdiffuse objet

L'éclairage diffus a quand à lui une intensité qui dépend du produit scalaire entre la normale à la surface et la direction de la lumière :
   cpixel  =  SOMME source i [ (Li . N) * csource i * cdiffuse objet   ]

N est la normale au point intersecté sous le pixel, et Li la direction depuis ce point vers la lumière. Le produit scalaire Li . N doit être positif. Le mettre à 0.0 sinon pour que les surfaces qui ne font pas face à la lampe ne soient pas éclairées. La direction Li est constante pour une DirectionalLight et dirigée vers la lampe pour une PointLight.

Ecrire une méthode Color illuminatedColor(Hit&) const dans Scene. Cette méthode prend en entrée la position, la normale et le matériau d'un point, regroupés dans le Hit. Elle renvoie la nouvelle couleur de ce point, éclairé par les différentes lampes de la scène. Cette méthode se contente de sommer les résultats fournis par chacune des lampes, via une méthode virtuelle du même nom que vous coderez dans Light et ses classes dérivées. Phong

Ajoutez l'éclairage de Phong à votre calcul d'éclairage. Il prendra en compte la specularColor() et le specularCoefficient() du matériau. Il va falloir donner un paramètre supplémentaire à la méthode illuminatedColor() de Light.

Partie VIII - Les ombres

Bruit Pour générer des ombres, il suffit de vérifier que chaque source lumineuse est bien visible depuis le point d'intersection. Une source ambiante est toujours visible. Sinon, lancer un rayon depuis le point d'intersection en direction de la lumière, et voir s'il y a intersection. Pour les PointLight, il faut de plus comparer le temps d'intersection avec la distance à la lampe: s'il est inférieur, un objet bloque la lumière. Si c'est le cas, la contribution de la lampe doit être ignorée.

Ajoutez à la classe Light une méthode virtuelle bool visibleFrom(const qglviewer::Vec& pos, const Scene* const scene) const et réimplémentez-la dans chaque classe dérivée. Il faut passer la scène en paramètre pour pouvoir lui faire des requêtes d'intersection.

Vous allez probablement obtenir des images bruitées. En effet, les imprécisions numériques font qu'il peut exister une intersection entre un rayon partant de la surface d'un objet et l'objet lui-même. Pour les corriger, contraignez le temps d'un Hit à être légèrement supérieur à 0.0 grâce à un epsilon. Ombres

Partie IX - Plusieurs rebonds

Chemin lumineux Un des grands intérêt du lancer de rayons est que la gestion des surfaces réflechissantes se fait via un simple appel récursif. Il suffit d'ajouter à la couleur d'un point celle d'un rayon lancé depuis ce point dans la direction mirroir à celle d'arrivée (par rapport à la normale).

Il suffit donc de rappeler RayTracer::rayColor() pour obtenir le résultat. La couleur du rayon mirroir est pondérée par la reflectiveColor() du matériau. Après un certain nombre de rebonds ou lorsque le rayon n'intersecte plus d'objets, il a une contribution nulle (couleur 0,0,0).

En revanche, lorsqu'un rayon partant de l'oeil n'intersecte aucun objet, on souhaite lui donner la backgroundColor définie dans la scène. Pour différencier ces deux cas, on ajoute un paramètre booléen fromEye à la méthode rayColor() du RayTracer. Reflection

Pour vérifier votre algorithme, ajouter au RayTracer un QValueVector (QVector avec Qt4) de Segment, qui va représenter un chemin lumineux. Un Segment est une classe privée du RayTracer qui contient les deux extrémités d'un segment ainsi que la normale au point d'arrivée.
La méthode rayColor() concatène dans le chemin le segment correspondant au Ray qu'elle est en train de traiter. Une méthode drawRayPath affiche le chemin lumineux ainsi stocké.

Conseils : un constructeur de Segment pourra prendre un Ray et un Hit en paramètres. Définissez une méthode draw() dans Segment. N'oubliez pas de vider le chemin lumineux au départ du rayon. C'est rayColor et non plus intersect qu'il faut appeler dans le select() du viewer.

Partie XI - Textures

Textures dans le Matériau

On souhaite désormais texturer nos objets. La classe Material gère les textures, et peut charger la plupart des formats de fichiers grâce à Qt. Une texture est définie par un nom de fichier, au chemin défini par rapport à l'endroit d'où vous lancez l'éxecutable.

Material permet également de définir un textureMode() qui indique comment combiner la diffuseColor() d'un matériau avec celle de sa texture. Les trois modes possibles sont MODULATE, BLEND et REPLACE (voir le code et la documentation de glTexEnv() pour les détails). Enfin un paramètre textureScale[U/V] permet de régler l'échelle de la texture sur l'objet.

La méthode diffuseColor(float u, float v) de Material prend en compte tous ces paramètres et donne la couleur voulue. Les coordonnées u et v sont quelconques, les valeurs hors de [0,1] étant ramenées dans cet intervalle. Si aucune texture n'est définie, elle renvoie la diffuseColor classique.

Coordonnées de textures

UV Ajoutez dans la classe Hit deux float u,v pour stocker les coordonnées de texture du point d'intersection (ainsi que les méthodes associées).

Mettez ces valeurs à jour dans la méthode intersect() de Sphere. On utilisera la longitude et la latitude comme coordonnées u et v, en les ramenant dans l'intervalle [0,1]. La fonction atan2 vous sera probablement utile ici. Vous pouvez régler la couleur d'un pixel en fonction des u,v du point d'intersection pour vérifier votre calcul (cf image). Texture

Enfin, utilisez la nouvelle fonction diffuseColor(float u, float v) à la place de la précédente lors du calcul d'éclairage dans les classes Light pour prendre en compte la couleur de la texture. J'ai choisi pour réduire le code d'ajouter à la classe Hit une méthode diffuseColor() qui renvoie la diffuseColor() du matériau, aux coordonnées u et v stockées dans le Hit.

Partie XII - Anti-aliassage

Un zoom sur les discontinuités de l'image obtenue dévoile les problèmes d'aliassage de notre méthode. Pour les réparer, il suffit de lancer plusieurs rayons pour chaque pixel et de moyenner leurs couleurs.

Modifier la méthode renderImage de RayTracer pour qu'elle lance plusieurs rayons, par exemple à travers une grille régulière nxn, placée dans chaque pixel. Les images suivantes (grossies 3 fois) montrent les résultats pour n=1, 2, 3 et 4.
SuperSampling 4 SuperSampling 3 SuperSampling 2 SuperSampling 1
Ces résultats peuvent être améliorés et étudiés en détail, et vous pourrez le faire en tant qu'extension.

Conclusion

Vous avez été très guidés pour arriver à ce stade, et j'espère que vous avez pu y parvenir dans un temps raisonnable. S'il vous reste du temps, vous pouvez ajouter des extensions à votre programme.

Vous pouvez m'envoyer un mail lorsque vous arrivez ici (avec quelques images ou des fichiers de scène). C'est simplement pour que j'estime la durée du TP, je n'en tiendrai pas compte dans la note. Nous discuterons alors éventuellement des extensions.

Bétisier

Vous en aurez probablement quelques unes du même genre. Conservez-les, vous pourrez les montrer à la soutenance, c'est intéressant aussi.
Texture Texture Normales normales ?