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.
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 :
///
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 :
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.
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.01
f <
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.
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.01
f <
t_dist) &&
(t_dist <
dist))
{
return
true
;
}
}
return
false
;
}
Il ne reste plus qu'à donner le code d'une lumière :
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 :
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 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 :
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.