MOS8.5 - Centrale Lyon

TP1 - pour démarrer


Partie 1 : installation

Le TP se programme en C++ en utilisant une base de code minimaliste qu'il faut installer. cf doc en ligne.

Il faudra éventuellement vous familiariser avec premake et git, en plus d'un environnement de développement C++.
La base de code est portable sous linux et windows mais également mac os.

Si vous ne souhaitez pas utiliser git, vous pouvez néanmoins récupérer un zip des sources sur le dépot.

Compilez et exécutez tp1.cpp ou tp2.cpp qui se trouvent dans le dossier projets.

Partie 2 : intersections

Pour commencer en douceur : comment calculer l'intersection d'un rayon et d'un plan ? Comment représenter un rayon ? un plan ?

On représente généralement le rayon par son origine, sa direction et une abscisse (paramétrique) max. Un rayon peut être une demi-droite infinie ou un segment.

#include "vec.h"    // pour Point et Vector

struct Ray

{
    Point o;                // origine
    Vector d;               // direction
    float tmax;             // position de l'extremite, si elle existe. le rayon est un segment / un intervalle [0 tmax]
   
    // le rayon est un segment, on connait origine et extremite, et tmax= 1
    Ray( const Point& origine, const Point& extremite ) : o(origine), d( Vector(origine, extremite) ), tmax(1) {}
   
    // le rayon est une demi droite, on connait origine et direction, et tmax= inf
    Ray( const Point& origine, const Vector& direction ) : o(origine), d(direction), tmax(FLT_MAX) {}
   
    // renvoie le point sur le rayon pour t
    Point point( const float t ) const { return o + t * d; }
};
On représente habituellement le plan par un point et une normale. cf les types Point et Vector définis dans vec.h (et vec.cpp)

Écrivez l'intersection rayon / plan.
rappel : la droite infinie portant le rayon peut toucher le plan, mais cette intersection n'est pas forcément valide pour le rayon, ie derrière l'origine ou trop loin...

Et pour une sphère de centre c et de rayon r ? Écrivez aussi l'intersection rayon / sphère.
rappel : on ne s'intéresse qu'à l'intersection valide la plus proche de l'origine du rayon...

Comment vérifier que le code est correct ? Quels cas faut-il tester pour s'en assurer ?

Partie 3 : camera

Maintenant que le code de base est fonctionnel, comment décrire une camera et construire les rayons passant par les pixels de l'image ?

rappel : on peut schématiser la construction de l'image par un plan image à z= -1 :



Il ne reste plus qu'à calculer les coordonnées du centre d'un pixel (x, y) dans le plan image. On connait l'origine du rayon par construction, c'est le point o et le rayon passe par le point e, le centre du pixel, ce qui permet de construire sa direction. On considère que les rayons issus de la camera sont infinis.

Maintenant que l'on sait calculer le rayon d'un pixel, il ne reste plus qu'à écrire la boucle qui parcours tous les pixels de l'image. cf doc de la classe Image. vous pouvez utiliser projets/tp2.cpp comme code de départ.

Dernière étape, calculer les intersections des objets et des rayons pour déterminer quel est l'objet visible à travers chaque pixel.
rappel : on ne s'intéresse qu'à l'intersection valide la plus proche de l'origine du rayon...

remarque : les objets placés devant la camera ont leur coordonnée z < 0. On utilise un repère droit / classique pour le plan image, X vers la droite, Y vers le haut et Z pointe donc derrière la camera.

Comment distinguer les différents objets ? Le plus simple est de donner une couleur différente à chaque objet. par exemple : un plan gris neutre, une sphère rouge et un ciel noir... cf doc de Color.



vous pouvez reproduire cette image avec les paramètres : le plan est paramétré par un point (0 -1 0) et une normale (0 1 0) . la sphère est de centre (0 0 -3) et de rayon 2 .

indications : généralement les fonctions d'intersections rayon / primitive renvoient plusieurs informations, l’abscisse de l'intersection bien sur, sa couleur (ou sa matière) en plus de la position et de la normale que l'on utilisera pour évaluer la quantité de lumière réfléchie par l'objet, mais ce sera pour plus tard...
struct Hit
{
    float t;
    Point p;
    Vector n;
    Color color;    // ou Material material; si avez une description de matière plus complète, avec des reflets par exemple...)
};


Partie 4 : ombres

On suppose qu'un soleil éclaire la sphère. Comment dessiner l'ombre de la sphère ?



les défauts d'intersections se corrigent assez facilement : cf doc en ligne : "precision numérique et lancer de rayons"


Partie 5 : lumière et matière

Quel est le modèle de matière le plus simple ?
rappel : il existe une relation entre le cosinus de l'angle entre 2 vecteurs et leur produit scalaire...



Partie 6 : nettoyage

Bravo vous venez d'écrire votre (premier ?) lancer de rayon ! Maintenant, il est conseillé de passer un peu de temps à nettoyer / restructurer le code que vous venez d'écrire...

Pour la suite, on va créer beaucoup de rayons et ajouter des milliers d'objets (des triangles surtout...). Il faut prévoir de représenter une "scène" composée de plusieurs objets et qui fournit 2 fonctions de visibilité, par exemple :
struct Scene
{
    std::vector objets;
   
    Hit intersect( const Ray& ray );
    bool visible( const Point& p, const Point& q );
   
    // const Material& material( const Hit& hit ); // renvoie la matiere au point d'intersection
};
ce qui permet d'abstraire la représentation de la scène et de la faire évoluer sans modifier son utilisation. ce sera très pratique lorsque l'on regardera comment accélérer les calculs d'intersection rayon / primitives.

pourquoi 2 fonctions de visibilité ? intersect() est adaptée aux rayons infinis comme ceux issus de la camera, la fonction doit renvoyer l'intersection valide la plus proche de l'origine du rayon.
visible() est utilisée pour vérifier que 2 points se voient, pour calculer les ombres, par exemple.
on peut utiliser des algorithmes plus rapides dans le cas de visible(), ie ce n'est pas la peine de vérifier qu'une intersection valide est bien la plus proche de l'origine du rayon...

Les prochains tp vont manipuler plus de géométrie (que l'on peut charger facilement avec read_positions(), par exemple) plus de rayons et le programme va devenir de plus en plus lent, mais il est très simple d'utiliser openMP pour le paralléliser et utiliser tous les cœurs du processeur d'une machine. Il suffit d'ajouter une ligne avant la boucle de parcours des pixels de l'image :
    Image image(512, 512);
   
#pragma omp parallel for schedule(dynamic, 1)
    for( int py= 0; py < image.height(); py++ )
    for( int px= 0; px < image.width(); px++ )
    {
        Point o= Point(0, 0, 0);
        Vector d= { ... };
       
        Ray ray(o, d);
        Hit h= scene.intersect(ray);
        ...
    }
et de compiler en mode Release : make -j4 config=release tp2

On peut aussi prévoir de déplacer la camera ou les objets, en utilisant une transformation (cf doc en ligne). On peut aussi généraliser la projection de la camera en utilisant une transformation perspective ou orthographique,  etc... cf doc.