IRT : un ray tracer interactif - Partie 2

Matthieu Brucher

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

Il est temps d'ajouter la gestion des reflets et des lumières.

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

- Auteur : Matthieu Brucher

- Parution : 14 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. Gestion des reflets

- III. Gestion des lumières

- IV. Résultats

- IV-A. Résultat brut

- IV-B. Profil du raytracer

- V. Conclusion

I - Objectifs

Les objectifs de ce deuxième tutoriel sont les suivants :

- Gérer les reflets

- Gérer les lumières

Ces deux étapes sont primordiales dans la conception d'un raytracer. Ce sont elles qui permettront d'obtenir un rendu réaliste.

L'encapsulation Python n'a pas beaucoup été modifiée. Les différences proviennent principalement de la gestion de la rotation au tour de l'axe vertical de la scène et de la rotation de la caméra autour de la direction de celle-ci. Ce code n'est pas primordial à la compréhension de cette partie et a donc été passé sous silence.

II - Gestion des reflets

Lorsqu'un rayon lumineux frappe un objet, celui-ci est réfléchi et continue à se propager dans la scène. Ici, on reprend l'approximation de Whitted. Il est à l'origine de l'essort du raytracing en approximation la propagation des ondes lumineuses. Lorsque l'une d'elles frappe un objet, 3 types de rayons peuvent être générés : une réflection, une réfraction et des ombres. Ces dernières font l'objet du prochain point, les réfractions étant pour le moment oubliées.

Réflexion des raysons sur les objets

Chaque objet reflète plus ou moins la lumière d'un autre. Le miroir la reflète totalement, un objet mat ne reflètera rien. Il faut aussi noter que chaque réflexion lumineuse est un nouveau lancer de rayon, donc potentiellement coûteux. En général, on arrête la recherche des réflexions au bout de quelques itérations.

Le rayon lumineux reflété se calcule par rapport à la normale à l'objet. En effet, il s'agit simplement de la symétrie du rayon incident par rapport à cette normale.

Tous ces éléments (normale, couleur de l'objet, comment l'objet reflète la lumière) sont stockés dans une structure spécifique :

primitives.h

/// Carateristique d'une primitive en un point donné
struct MaterialPoint
{
/// Normale de la primitive en un point
Normal3df normal;
/// Couleur du point
Color color;
/// "Couleur" de la réflexion
Color reflect;
};

Ici, le coefficient de réflexion est un vecteur lui-aussi. Le choix a été fait de permettre à un objet d'être un quasi-miroir pour certaines couleurs (valeur de la composante proche de 1) et d'être complètement mat pour d'autres (valeur de la composante proche de 0). Ce choix n'a pas d'incidence sur le code et le nombre de multiplications n'augmente pas, donc l'impact est quasiment nul, tout en ouvrant de nouvelles perspectives (on peut par exemple penser à l'utilisation du raytracer en acoustique où le nombre de composantes de "couleur" est bien plus important et correspondant à des bandes de fréquences sonores et où un obstacle peut absorber ou réfléchir différemment selon la fréquence du rayon).

Une fois cette structure en place, il suffit de modifier la foncton principale du raytracer :

raytracer.cpp

void Raytracer::computeColor(const Ray& ray, Color& color, unsigned int level) const
{
float dist;
long index = scene->getFirstCollision(ray, dist);
if(index < 0)
return;

Primitive* primitive = scene->getPrimitive(index);
MaterialPoint caracteristics;
primitive->computeColorNormal(ray, dist, caracteristics);
color = caracteristics.color;

if(level < levels)
{
Ray ray_sec(ray.origin() + dist * ray.direction(), ray.direction() - (ray.direction() * caracteristics.normal) * 2 * caracteristics.normal);
ray_sec.direction() *= 1./(sqrt(norm2(ray_sec.direction())));
Color color_sec(0.);
computeColor(ray_sec, color_sec, level+1);

color += mult(color_sec, caracteristics.reflect);
}
}

Si on exécute le code ci-dessous avec la méthode SimpleScene::getFirstCollision présentée lors du tutoriel précédent, des erreurs vont apparaître. En effet, lors de la réflexion, on teste si le rayon réfléchi touche d'autres objets. Le problème est qu'il peut commencer par toucher... l'objet d'où il part. Cela est dû aux approximations numériques, donc pour valider la collision entre un rayon et un objet, on ajoute une condition sur la distance de l'objet : celle-ci doit être supérieure à une valeur strictement positive.

scene.cpp

long SimpleScene::getFirstCollision(const Ray& ray, float& dist)
{
float min_dist = std::numeric_limits<float>::max();
long min_primitive = -1;

for(std::vector<Primitive*>::const_iterator it = primitives.begin(); it != primitives.end(); ++it)
{
float dist;
bool test = (*it)->intersect(ray, dist);

if(test && (0.01f < dist) && (dist < min_dist))
{
min_primitive = it - primitives.begin();
min_dist = dist;
}
}

if(min_primitive == -1)
return -1;
else
{
dist = min_dist;
return min_primitive;
}
}

Maintenant, lorsqu'un rayon touche une primitive, on passe une structure de matériau que la primitive remplira lors du calcul de la normale. La couleur vue sans compter les prochaines réflexions sera directement celle de l'objet.

L'argument level indique le nombre de niveaux de réflexion atteint. Si ce nombre est plus petit que la variable constante levels, on calcule le rayon refléchi et on recherche la couleur du rayon qui est reflété sur l'objet. Par la suite, on ajoute à la couleur vue une fraction de cette dernière couleur, selon que l'objet reflète plus ou moins bien.

III - Gestion des lumières

Sans lumière, il n'y a pas d'ombre, les objets restent uniformément "éclairés", fades, ... La lumière dans une scène est émise par plusieurs sources lumineuses. Lorsqu'un rayon de la caméra frappe un objet, le point d'intersection peut être éclairé par une ou plusieurs lumières. Chacune d'elles contribue un peu à la couleur qui sera effectivement vue. Si la source lumieuse est perpendiculaire à l'objet au point d'intersection, la lumière captée sera maximale. Si elle est orthogonale, la lumière sera nulle. On utilise dont le produit scalaire entre la direction de la source lumineuse et la normale pour calculer la quantité de lumière obtenue.

On va donc tester chaque lumière, l'une après l'autre. Si aucun objet n'est entre l'objet et la lumière (ceci étant symbolisé par un rayon partant de l'objet vers la lumière avec une distance maximale), on ajoute la contribution de cette lumière.

simple_scene.cpp

const Color SimpleScene::computeColor(const Point3df& center, const MaterialPoint& caracteristics)
{
Color t_color(0.);
for(std::vector<Light*>::const_iterator it = lights.begin(); it != lights.end(); ++it)
{
Vector3df path = (*it)->getCenter() - center;
float pathSize = sqrt(norm2(path));
path.normlize();
Ray ray(center, path);
if(testCollision(ray, pathSize))
continue;

float cosphi = (path * caracteristics.normal);
if(cosphi < 0.)
continue;
t_color += mult((caracteristics.color * cosphi), (*it)->computeColor(ray, pathSize));
}

bool SimpleScene::testCollision(const Ray& ray, float dist)
{
for(std::vector<Primitive*>::const_iterator it = primitives.begin(); it != primitives.end(); ++it)
{
float t_dist;
bool test = (*it)->intersect(ray, t_dist);

if(test && (0.01f < t_dist) && (t_dist < dist))
{
return true;
}
}
return false;
}

Il ne reste plus qu'à donner le code d'une lumière :

light.cpp

Color Light::computeColor(const Ray& ray, float dist)
{
return color * (1. / (dist * dist));
}

const Vector3df& Light::getCenter() const
{
return center;
}

La quantité de lumière émise par une lumière ponctuelle est constante sur la surface d'une sphère centrée sur cette lumière. La lumière atteignant un point sur cette sphère est donc fonction de l'inverse du rayon au carré.

Le code de la fonction principale du raytracer doit maintenant être modifiée pour que la couleur de l'objet lors du calcul prenne en compte les lumières de la scène :

raytracer.cpp

void Raytracer::computeColor(const Ray& ray, Color& color, unsigned int level) const
{
/// ...
color = scene->computeColor(ray.origin() + dist * ray.direction(), caracteristics);
/// ...
}

IV - Résultats

IV-A - Résultat brut

Voici le résultat affiché par le ray tracer :

La scène avec reflets et lumières

La figure s'affiche différemment dans l'article précédent. Cette modification d'orientation selon l'axe y a été effectué pour une compatibilité avec un affichage sur un canevas OpenGL (ce qui sera vu dans un prochain tutoriel). Au fur et à mesure de l'avancée du projet, certains aspects peurront être modifiés.

IV-B - Profil du raytracer

Le ray tracer trace cette simple scène en 170 ms sur un Xeon 3.8GHz sous Linux avec GCC 4.1.2.

Voici donc ce profil en mode optimisé calculé par Valgrind, sous Linux :

Profil de la fonction principale draw()

Par rapport au profil de la première partie, un temps plus important est passé dans la méthode de génération des rayons Raytracer::generateRay. Effectivement, à cause de la prise en compte des orientations, celle-ci est bien plus complexe. Il s'agit vraisemblablement de la méthode qui bénéficiera le plus d'une optimisation.

On note aussi la récursion des appels à la méthode Raytracer::computeColor.

Grâce à un article précédent dédié à la compréhension des profils, de nombreuses autres fonctions ont été optimisées. Par exemple la méthode virtuelle Sphere::intersect voit son temps d'exécution diminuer de 40%. A cause de la récursion introduite par les reflets, le temps global d'utilisation a naturellement augmenté, mais sans cette amélioration, le temps global d'exécution serait supérieur à 200ms.

V - Conclusion

La gestion des reflets et des lumières n'est pas terminée ici. En effet, que ce passe-t-il lorsqu'on veut ajouter la gestion de la transparence ? Et comment faire lorsque l'objet contient une fois une texture, l'autre fois une couleur, lorsqu'il contient une carte spécifique pour la normale, ... ?

La solution réside dans une profonde modification du code qui sera à venir. Elle consiste à associer à chaque objet le code qui gèrera aussi le calcul complet de la couleur. Pour l'instant, c'est la classe Raytracer qui s'en occupe. A terme, chaque primitive aura son propre calcul, avec gestion des reflets, de la transparence, des lumières, des textures, ... et donc une liste de fonctions usuelles et un champ additionnel contenant ce dont le calcul aura besoin (c'est l'une des seules fois où un pointeur void* me sera utile...).

Ces changements étant prévus, je n'ai pas ajouté de paragraphe sur l'optimisation. Ce sera le cas par la suite lorsque les structures d'accélérations seront introduites. Mais cela n'est pas pour tout de suite. Avant d'aborder le suréchantillonnage, je présenterai l'encapsulation de la bibliothèque dans une GUI en Python.