INFO804 Introduction à l'informatique graphique
|
author: Jacques-Olivier Lachaud
L'objectif de ce TP est de vous faire écrire un algorithme de rendu "réaliste" appelé lancer de rayons (ray-tracing ou eye-tracing en anglais).
Le principe est le suivant. On va calculer comment les rayons lumineux ont éclairé la scène observée. Ils ont rebondi ou traversé certains objets puis sont arrivés dans l'oeil. L'ensemble de ces rayons a donc formé une image sur notre rétine. Le problème de cette approche directe est que la plupart des rayons lumineux ne sont pas allés sur notre rétine mais sont allés ailleurs ! Il faudrait donc envoyer des milliards de milliards de rayons pour voir la scène se dessiner sur notre rétine.
L'idée du lancer de rayons est de calculer la couleur de chaque pixel indépendamment (un pixel étant un point sur la rétine) en reconstruisant le trajet inverse de la lumière. On part de l'oeil et on détecte où ce rayon va tomber dans la scène. Si ce rayon tombe sur un objet réfléchissant, on fait rebondir le rayon (réflexion) et on continue. Si ce rayon tombe sur un objet transparent, on le traverse (réfraction). Si ce rayon tombe sur un objet mat on calcule la couleur en regardant où sont les sources de lumière et on s'arrête (diffusion).
Les images ci-dessous vous donnent une idée de la qualité des rendus que l'on peut obtenir avec cet algorithme. Cela peut être très joli !
Les avantages de cet algorithme sont:
Les inconvénients de cet algorithme sont les suivants:
A l'issue de cette séance, vous vous serez bien amusés ! Plus sérieusement, vous aurez manipulé:
L'objectif du TP est:
Les sites suivants pourront être utile pendant le TP:
Afin de vous faciliter au mieux le travail et de vous concentrer sur la partie "graphique" du TP, on vous donne tout un ensemble de classes pour avoir un logiciel minimaliste pour démarrer:
Makefile
(avec qmake
)rt
.En plus d'un compilateur C++ (version c++11 au moins), il faut que vous ayez Qt (https://www.qt.io/) et libQGLViewer (https://github.com/GillesDebunne/libQGLViewer) installés pour que l'application fournie fonctionne. Ce n'est pas obligatoire d'avoir tout ça pour faire du ray-tracing mais c'est tellement plus agréable de placer sa caméra et ses lumières de façon interactive !
Pour faire le Makefile
:
$ qmake
Si qmake
ne marche pas, modifiez le fichier ray-tracer.pro
pour votre système.
Pour compiler:
$ make
Pour exécuter:
$ ./ray-tracer
Pour le moment, vous devriez avoir une fenêtre avec 2 lumières et deux sphères. Si vous tapez sur Ctrl+R, cela calculera un rendu par rapport au point de vue actuel. Le résultat est enregistré dans le fichier output.ppm
. Pour le moment l'image est noire !
C'est normal que le rendu soit noir.
Tout le code est nécessaire, mais vous devez regarder avec attention notamment ces parties du code:
méthode Viewer::keyPressEvent(QKeyEvent *e)
Cette méthode permet de lancer le rendu (en appuyant sur R avec possiblement Shift, Ctrl). Elle est intéressante car elle montre comment on utilise QGLViewer pour nous donner les rayons qui partent de l'observateur vers la scène. Les 4 vecteurs construits (dirUL
, dirUR
, dirLL
, dirLR
) correspondent aux rayons lumineux partant de l'oeil (origin
) vers les 4 coins de la fenêtre. Le reste du code paramètre l'objet qui s'occupe du rendu et sauvegarde l'image.
méthode Renderer::render
Cette méthode s'occupe de lancer dans la scène 1 rayon par pixel de l'image en entrée. On voit comment la drection du rayon de chaque pixel est interpolée (i.e. moyenne pondérée) à partir des directions des 4 rayons correspondant aux 4 coins de l'image. Pour chaque rayon/pixel, on appelle la méthode Render::trace qui est l'algorithme de lancer de rayon proprement dit. Le résultat est ensuite mis dans l'image de sortie.
Avant de se préoccuper trop des couleurs, on va se préoccuper de savoir quand un rayon touche un objet dans la scène.
On voit que la méthode Sphere::rayIntersection est vide. Reprenez l'exercice du TD pour calculer quand un rayon intersecte une sphère. On rappelle les points-clés, si [o,u) est le rayon d'origin o et de direction u, et la sphère est de centre c et rayon r:
Pour le moment, rien n'a changé à l'affichage normalement. En effet la méthode Scene::rayIntersection est vide aussi. On l'écrit ainsi:
object
et p
correspondent à l'intersection la plus proche.Vous devriez maintenant obtenir un rendu comme ceci:
[o,u)
, avec u
vecteur unitaire, on peut calculer la distance entre le point p
d'intersection et le point o
de départ du rayon simplement en calculant le produit scalaire u.(p-o)
. Cela évite de potentiels problèmes de signe.Une fois que le premier objet touché par le rayon a été trouvé, on peut tout simplement afficher sa couleur ambiente (une sorte de couleur d'émission) et sa couleur diffuse. Il suffit d'aller modifier la méthode Renderer::trace. On voit qu'elle n'affiche que blanc ou noir pour le moment:
On voit qu'il faut modifier la dernière ligne. Comme on a un objet, il suffit de demander à cet objet son Material (via GraphicalObject::getMaterial) au point d'intersection. A partir du "Material", on pourra calculer la somme des couleurs diffuses et ambientes. Cela donne:
Evidemment, le rendu précédent est très grossier. On voudrait prendre en compte les sources de lumières, car on sait qu'elles vont changer la couleur perçue des objets. Dans le modèle usuel d'illumination, la couleur diffuse d'un objet ne dépend que de l'orientation de la normale et de la position de la source de lumière.
On va donc écrire une méthode Renderer::illumination qui va s'occuper pour le moment des parties ambientes et diffuses.
Le principe sera le suivant:
\[ C \leftarrow C + k_d D * B \]
Cela donne qqchose du genre:
Vous pouvez jouer à déplacer les lumières pour voir les changements. On a l'impression de matériaux mats. On peut aussi rajouter d'autres sources de lumière.
Quand on observe une scène éclairée par une forte source de lumière, on s'aperçoit qu'il y a des taches très brillantes qui apparaissent. Ces taches correspondent à la réflexion de la source de lumière sur des matériaux brillants.
On va simuler plus tard la réflexion des rayons, mais comme on ne simulera qu'un seul rebondissement de rayons, on ne pourra pas obtenir ces taches. En effet, physiquement, il faut bien imaginer que les rayons ne sont pas exactement réfléchis, et ces variations aléatoires créent ces taches. On va donc "tricher", et simuler directement ces taches comme on le fait en OpenGL, en calculant ce qu'on appelle la couleur spéculaire.
On met à jour la fonction Renderer::illumination
:
pow
)Pour le moment, on n'a pas grand chose de mieux que ce que le simple rendu OpenGL peut donner. Par exemple, on n'a pas les ombres portées, la réflexion ou la réfraction, ni le fond. On commence par le plus simple, un fond moins triste.
Il faut modifier la méthode Renderer::trace pour que, lorsque le rayon n'intersecte pas d'objet, une couleur spécifique soit renvoyée plutôt que la couleur noire. Ecrivez donc une structure Background
qui comportera une méthode (virtuelle) pour retourner la couleur dans une direction ray donnée.
Pour faire un dégradé pour le ciel, il suffit de regarder seulement la composante z de la direction du rayon ray. Par exemple, si z est entre 0 et 0.5, on retourne une couleur entre blanc (Color(1,1,1)
) et bleu (Color(0.0,0.0,1.0)
), puis si z est entre 0.5 et 1.0, on retourne une couleur entre bleu (Color(0.0,0.0,1.0)
) et noir ((Color(0.0,0.0,0.0)
). On rappelle que l'on peut mélanger des couleurs avec des multiplications, par exemple:
Ensuite, on associe un objet Background
au Renderer à sa création (dans Renderer::Renderer
donc). Enfin, on rajoute la méthode Renderer::background
qui s'occupe d'afficher les sources de lumière et votre fond:
Il ne reste plus qu'à appeler Renderer::background
depuis Renderer::trace
lorsque le rayon n'intersecte rien.
On peut maintenant rentrer dans le vif du sujet. On va s'occuper de rendre plus réaliste le rendu en simulant le trajet des rayons lumineux.
Actuellement, pour calculer le rendu en un point p d'un objet, on regarde les sources de lumières et on additionne les couleurs diffuses et spéculaires correspondantes. Or, si un autre objet se situe entre p et la source de lumière, il devrait y avoir occultation. Autrement dit, le point p est dans l'ombre par rapport à la source de lumière.
On va donc créer une méthode Renderer::shadow
qui va calculer un coefficient d'ombrage dans une direction vers une source de lumière. Le principe est d'atténuer la lumière si le rayon intersecte des objets en allant vers la lumière (atténuation complète si un objet traversé est opaque, partielle sinon en fonction de la transparence de l'objet (réfraction) et de sa couleur).
Que serait un ray-tracer sans les reflets ? Pas grand chose. En réalité les couleurs proviennent toujours d'une réflexion d'un rayon lumineux ou de sa transmission par transparence. La notion de couleur diffuse (ou mat) et de spécularité sont des approximations bien commodes pour du rendu rapide, mais traduisent en réalité des moyennes pour des zillions de rayons lumineux qui touchent le point observé.
On va simplifier beaucoup tout ça en ne calculant au maximum qu'un rayon réfléchi (et dans la question suivante un rayon réfracté). De plus, on ne va réfléchir le rayon qu'un nombre fini de fois (la fameuse profondeur maximale ou max_depth
donnée à Renderer::render).
On va mettre à jour la méthode Renderer::trace. Le principe de la réflexion est très simple. Au point p d'intersection, si le matériau est réfléchissant (coef_reflexion != 0 ), on lance un rayon dans la direction réfléchie en appelant Renderer::trace récursivement. Si le matériau n'est pas réfléchissant ou si la profondeur maximale est atteinte, on retourne Renderer::illumination
comme avant. En pseudo-code, Renderer::trace devient:
background( ray )
La réfraction de la lumière est le principe physique qui fait qu'une partie des rayons lumineux traverse certains matériaux plutôt que de rebondir dessus. Contrairement à la réflexion, la direction du rayon réfracté dépend du milieu physique en entrée et en sortie. Ainsi, l'eau déforme les rayons lumineux qui viennent de l'air. C'est pourquoi on a l'impression que le fond de l'eau est grossi.
De façon générale, cela provient du fait que la lumière change de vitesse selon la matériau. La lumière est plus lente dans l'eau que dans l'air par exemple. On modélise tout cela simplement à l'aide d'un nombre, l'index de réfraction. Par exemple, l'air a un indice de 1 environ, l'eau liquide de 1.33, le verre de 1.5, etc.
Ensuite, le ratio \( r=n1/n2 \) entre l'indice de réfraction du milieu en entrée n1 et celui du milieu de sortie n2, influence la direction du rayon réfracté (en fait sa vitesse aussi, mais on ne s'en occupe pas ici). La loi de Snell s'écrit alors, si V est la direction du rayon en entrée et N la normale:
\[ V_{refract} = r V + \left( rc - \sqrt{1 - r^2( 1 - c^2 )} \right) N \]
avec \( c = - N \cdot V \). Ecrivez donc d'abord la méthode Renderer::refractionRay
qui calcule le rayon réfracté, de prototype suivant:
Il ne suffit plus alors qu'à faire la même chose que pour la réflexion, en appelant récursivement Renderer::trace avec le rayon réfracté et en sommant la couleur résultante en tenant compte du coefficient de réfraction et de la couleur diffuse.
Vous devez observer que les temps de calcul ont fortement augmenté depuis l'introduction de la réfraction et de matériaux transparents. C'est normal, maintenant les rayons se divisent sur de tels surfaces, et un rayon devient l'addition de \( 2^d \) rayons, où d est la profondeur maximum. Notez que le paramètre max_depth
est important dans certaines scènes, et doit parfois être augmenté. Voilà ci-dessous une même scène visualisée avec un paramètre de profondeur maximale croissant:
Vous avez maintenant une bonne base pour commencer à faire de jolies images. Ajoutez des sphères, modifiez des matériaux et proposez-en de nouveaux. Placez des lumières, etc. Modifiez les couleurs du fond pour un effet nuit / aube / plein jour. Un peu de créativité et vous pouvez faire de très jolies images ...
Cette section propose diverses extensions, améliorations et enrichissement à votre ray-tracer. Tout n'est pas à faire, c'est juste pour vous donner des idées d'extensions possibles. Si vous avez d'autres idées, n'hésitez pas non plus. Chaque amélioration proposée est en général indépendante des autres et, soit elle améliore le rendu, soit elle l'accélère, soit elle l'enrichit avec de nouvelles primitives géométriques. Le nombre d'étoiles (*, **, ***) indique la "difficulté" de l'extension.
Pour le moment, on n'a pas de sol à nos scènes. On va en rajouter en définissant un nouveau GraphicalObject, que l'on appelera PeriodicPlane
. Pourquoi périodique ? C'est juste qu'on pourra définir le matériau en un point périodiquement en fonction de ses coordonnées dans le plan. Son constructeur ressemblera à
Le plan ainsi créé est un plan passant par le point c, avec deux vecteurs orthogonaux tangents u et v qui vont définir les coordonnées du point d'intersection dans le plan. Maintenant, si p est le point d'intersection entre un rayon et le plan, p pourra aussi être défini à l'aide des deux coordonnées x et y selon les vecteurs u et v.
Ensuite les deux matériaux servent à définir l'aspect du plan infini. Si le point p a des coordonnées x ou y proche d'un entier, alors on utilise le matériau pour les bandes (band_m), sinon on utilise le matériau principal (main_m). Le réel w, plus petit que 1, est l'épaisseur de la bande.
Vous écrirez une méthode pour calculer les coordonnées d'un point dans le plan.
Il faut maintenant implémenter toutes les méthodes de GraphicalObject:
Avec votre nouvelle classe, on peut facilement créer des sols ou des murs variés:
On peut adapter le plan infini pour faire de l'eau calme. L'idée est de garder la géométrie du plan infini (donc un simple plan), mais de perturber les normales selon des sommes de sinusoïdes. En gros, WaterPlane::getNormal( Point p )
dépend des coordonnées x et y de p dans le plan.
Pour l'eau, on utilise le Material suivant:
Les sinusoïdes s'écrivent sous la forme:
\[ t(x,y) = x \cos a + y \sin a \]
\[ f(x,y) = r \cos( 2 \pi t(x,y) / l + \phi ) \]
avec:
Vous écrirez votre WaterPlane::getNormal
en retournant le vecteur normal à ces sommes de sinusoïdes.
Ce système de perturber les normales marche pour plein de matériaux !!
Pour le moment, on envoie un seul rayon par pixel. Cela marche très bien tant que la couleur en ce pixel n'est pas trop dépendante d'une petite déviation de la direction du rayon. En revanche, vous pouvez voir qu'à certains endroits (aux bords des objets notamment, ou lorsqu'il y a une brusque variation du matériau), on observe des petits défauts dans l'algorithme de rendu. A ces endroits, on a à la fois des effets d'aliasing (pixelisation) et des erreurs numériques (dans les calculs des rebonds de rayons).
Pour corriger ce problème, vous pouvez écrire une fonction Renderer::randomRender
qui, au lieu d'envoyer un rayon par pixel, envoie plusieurs rayons au hasard dans le pixel (entre 10 et 20). On fait ensuite la moyenne des couleurs pour l'affecter au pixel. On peut accélérer le processus en observant l'écart entre la couleur moyenne actuelle et la couleur donnée par le dernier lancer de rayon. Si il est très proche et que c'est au moins le 5ème, on stoppe de suite. Ce nouvel algorithme de rendu est en général entre 5 et 10 fois plus lent que le précédent, mais le rendu est de meilleure qualité.
Sur le même principe que le plan infini, on peut définir une classe Triangle
comm nouveau GraphicalObject. Le triangle est défini tout simplement par 3 points A, B et C dans l'espace. Les deux vecteurs \( \vec{AB} \) et \( \vec{AC} \) définissent le plan tangent au triangle, et la normale \( \vec{N} \) est obtenue par leur produit vectoriel. Pour savoir si un point p appartenant au plan tangent est à l'intérieur du triangle, on résoud le petit système suivant:
\[ \alpha \vec{AB} + \beta \vec{AC} + \gamma \vec{N} = p - A \]
En fait si p appartient au plan, \( \gamma = 0 \). Ensuite, on prend le vecteur \( \vec{V} \) orthogonal à \( \vec{AB} \) et à \( \vec{N} \) (calculé par produit vectoriel), et on fait son produit scalaire avec le système précédent. Comme \( \vec{AB} \cdot \vec{V} = 0 \) Il reste:
\[ \beta \vec{AC}\cdot \vec{V} = (p - A) \cdot \vec{V} \]
On déduit directement \( \beta \). En le réinjectant dans l'équation au-dessus, on trouve \( \alpha \). Or p appartient au triangle si et seulement si
\[ 0 \le \alpha, 0 \le \beta, \alpha + \beta \le 1. \]
En assemblant des triangles, on peut faire des pyramides ou des surfaces triangulées complexes. Le code ci-dessous construit une pyramide avec 4 triangles.
C'est en fait assez facile de faire un TexturedTriangle
une fois que la question 5.4 est traitée. Il s'agit de créer un Material qui est associé à une image couleur (utilisez la classe Image2D et votre Image2DReader du TP C++ pour charger une image de texture). Ensuite, on place les coordonnées (0,0) en haut à gauche de l'image, le (1,0) en haut à droite, le (0,1) en bas à gauche et le (1,1) en bas à droite. Maintenant, dans la classe triangle, vous devez calculer \(
\alpha \) et \( \beta \) pour savoir si vous êtes dans le triangle. Or il s'agit exactement de coordonnées dans l'image de texture. Du coup, au lieu de retourner tout le temps le même matériau au point considéré, vous retournez un matériau dont la couleur diffuse est la couleur de la texture aux coordonnées \( (\alpha, \beta) \).
On peut adapter la question 5.3 d'une autre manière pour à la fois éliminer l'anti-aliasing et faire un algorithme dont la complexité n'explose pas avec la profondeur, et qui est beaucoup plus rapide en pratique. L'idée est simple: notons a le coefficient de diffusion du matériau, b son coefficient de réflexion et c son coefficient de réfraction. Il faut que \( a+b+c=1 \) (c'est assez normal, sinon le matériau crée de l'énergie). Pour le moment, vous calculer la couleur diffuse et vous l'atténuez avec le coefficient a, puis vous lancez potentiellement un rayon réfléchi que vous atténuez avec b, et enfin vous lancez potentiellement un rayon réfracté que vous atténuez avec c. On voit qu'à chaque profondeur il y a risque de doubler le nombre de rayons.
La tactique de rendu que l'on propose de faire maintenant est en un sens presque plus simple. A chaque fois que vous rentrez dans Renderer::trace
, vous allez tirer un nombre aléatoire x entre 0 et 1 et vous calculez alors:
Ensuite, comme vous lancez maintenant une centaine de rayons par pixel, la moyenne des couleurs fera bien le même résultats que l'algorithme de la question 5.3.
La bibliothèque libQGLViewer permet très facilement d'enregistrer un déplacement de caméra, puis de le rejouer. Vous pouvez regarder l'aide de votre application pour voir comment le faire. On peut aussi facilement jouer une animation (voir l'example sur le site). En dérivant de Viewer::animate(), vous pouvez donc placer une routine qui génère un rendu à chaque pas d'animation.
On peut utiliser des photos de ciel pour faire un Background plus joli. L'idée est d'utiliser une photo "fish-eye", puis selon la direction du rayon, on renvoie la valeur d'un pixel de la photo.
Il y a malheureusement des petites subtilités dans la transformation (me demander) et il faut aussi faire de l'interpolation linéaire dans l'image. De façon plus générale, on utilise souvent des images de fonds dans les ray-tracer, pour le ciel et pour le sol.
On ne fait pas d'omelettes sans casser des oeufs. Vous allez voir que vous allez avoir des résultats surprenants de rendu. Les erreurs sont aussi formatrices, donc gardez vos meilleures erreurs pour le CR. En voici quelques unes de ma part:
Vous me remettrez votre TP via TPLab en binôme avant le lundi 12 février 2024 minuit.
L'archive doit contenir:
Une partie de la note est liée aux extensions réalisées et à votre créativité graphique. Les plus belles réalisations seront mis en ligne dans le wiki pour les générations futures.
Pour voir quelques résultats des promotions précédentes, c'est ici : Quelques images faites par les étudiants sur le tp ray-tracing