Premiers pas, Raytracer en C++

Voici le code d'un raytraceur basique, j'ai essayé de réduire les fonctionnalités au maximum tout en essayant de le conserver intéressant. Les commentaires avec explications suivent.

#include <iostream>
#include <fstream>
#include <vector>
#include <cmath>
#include <limits>
#include <algorithm>
using namespace std;

#include "Raytrace.h"

bool init(char* inputName, scene &myScene) 
{
   int nbMat, nbSphere, nbLight;
   int i;
   ifstream sceneFile(inputName);
   if (!sceneFile)
      return false;
   sceneFile >> myScene.sizex >> myScene.sizey;
   sceneFile >> nbMat >> nbSphere >> nbLight;

   myScene.matTab.resize(nbMat); 
   myScene.sphTab.resize(nbSphere); 
   myScene.lgtTab.resize(nbLight); 
   for (i=0; i < nbMat; i++) 
     sceneFile >> myScene.matTab[i];
   for (i=0; i < nbSphere; i++) 
     sceneFile >> myScene.sphTab[i];
   for (i=0; i < nbLight; i++)
     sceneFile >> myScene.lgtTab[i];
   return true;
} 

bool hitSphere(const ray &r, const sphere &s, float &t) 
{
   // intersection rayon/sphere 
   vecteur dist = s.pos - r.start; 
   float B = r.dir * dist;
   float D = B*B - dist * dist + s.size * s.size; 
   if (D < 0.0f) 
     return false; 
   float t0 = B - sqrtf(D); 
   float t1 = B + sqrtf(D);
   bool retvalue = false;  
   if ((t0 > 0.1f) && (t0 < t)) 
   {
     t = t0;
     retvalue = true; 
   } 
   if ((t1 > 0.1f) && (t1 < t)) 
   {
     t = t1; 
     retvalue = true; 
   }
  return retvalue; 
}

bool draw(char* outputName, scene &myScene) 
{
  ofstream imageFile(outputName,ios_base::binary);
  if (!imageFile)
    return false;    
  // Ajout du header TGA
  imageFile.put(0).put(0);
  imageFile.put(2);        /* RGB non compresse */

  imageFile.put(0).put(0);
  imageFile.put(0).put(0);
  imageFile.put(0);

  imageFile.put(0).put(0); /* origine X */ 
  imageFile.put(0).put(0); /* origine Y */ 

  imageFile.put((myScene.sizex & 0x00FF)).
            put((myScene.sizex & 0xFF00) / 256);
  imageFile.put((myScene.sizey & 0x00FF)).
            put((myScene.sizey & 0xFF00) / 256);
  imageFile.put(24);      /* 24 bit bitmap */ 
  imageFile.put(0);
  // fin du header TGA

  // balayage 
  for (int y = 0; y < myScene.sizey; ++y) { 
  for (int x = 0; x < myScene.sizex; ++x) {
    float red = 0, green = 0, blue = 0;
    float coef = 1.0f;
    int level = 0; 
    // lancer de rayon 
    ray viewRay = { {float(x), float(y), -10000.0f}, { 0.0f, 0.0f, 1.0f}};
    do 
    { 
      // recherche de l'intersection la plus proche
      float t = 20000.0f;
      int currentSphere= -1;

      for (unsigned int i = 0; i < myScene.sphTab.size(); ++i) 
      { 
        if (hitSphere(viewRay, myScene.sphTab[i], t)) 
        {
          currentSphere = i;
        }imageFile.put
      }

      if (currentSphere == -1)
        break;

      point newStart = viewRay.start + t * viewRay.dir; 
      // la normale au point d'intersection 
      vecteur n = newStart - myScene.sphTab[currentSphere].pos;
      float temp = n * n;
      if (temp == 0.0f) 
        break; 

      temp = 1.0f / sqrtf(temp); 
      n = temp * n; 
      
      material currentMat = 
          myScene.matTab[myScene.sphTab[currentSphere].material]; 

      // calcul de la valeur d'éclairement au point 
      for (unsigned int j = 0; j < myScene.lgtTab.size(); ++j) {
        light current = myScene.lgtTab[j];imageFile.put
        vecteur dist = current.pos - newStart;
        if (n * dist <= 0.0f)
          continue;
        float t = sqrtf(dist * dist);
        if ( t <= 0.0f )
          continue;
        ray lightRay;
        lightRay.start = newStart;
        lightRay.dir = (1/t) * dist;
        // calcul des ombres 
        bool inShadow = false; 
        for (unsigned int i = 0; i < myScene.sphTab.size(); ++i) {
          if (hitSphere(lightRay, myScene.sphTab[i], t)) {
            inShadow = true;
            break;
          }
        }
        if (!inShadow) {
          // lambert
          float lambert = (lightRay.dir * n) * coef;
          red += lambert * current.red * currentMat.red;
          green += lambert * current.green * currentMat.green;
          blue += lambert * current.blue * currentMat.blue;
        }
      }
        
      // on itére sur la prochaine reflexion
      coef *= currentMat.reflection;
      float reflet = 2.0f * (viewRay.dir * n);
      viewRay.start = newStart;
      viewRay.dir = viewRay.dir - reflet * n;

      level++;
    } 
    while ((coef > 0.0f) && (level < 10));   

    imageFile.put((unsigned char)min(blue*255.0f,255.0f));
    imageFile.put((unsigned char)min(green*255.0f, 255.0f));
    imageFile.put((unsigned char)min(red*255.0f, 255.0f));
  }
  }
  return true;
}

int main(int argc, char* argv[]) {
  if  (argc < 3)
    return -1;
  scene myScene;
  if (!init(argv[1], myScene))
    return -1;
  if (!draw(argv[2], myScene))
    return -1;
  return 0;
}

Voici l'output de ce programme:

à partir d'un fichier scene.txt comme ceci (sans les commentaires):

640 480 // taille du viewport 
3 3 2 // nbre de materiel, de spheres et de lumieres 
1.0 1.0 0.0 0.5 // premier materiel: rouge vert bleu et coef de reflexion 
0.0 1.0 1.0 0.5 // deuxieme materiel 
1.0 0.0 1.0 0.5 // troisiem materiel 
233.0 290.0 0.0 100 0 // sphere 1: posx, posy, posz, rayon, materiel id 
407.0 290.0 0.0 100 1 // sphere 2 
320.0 140.0 0.0 100 2 // sphere 3 
// light 1 : posx, posy, posz, intensité rouge, vert et bleu 
0.0 240.0 -100.0 1.0 1.0 1.0 
640.0 240.0 -10000.0 0.6 0.7 1.0 // light 2

Que fait le raytracer ?


le lancer de rayon ou raycasting/raytracing est l'une des méthodes de rendu les plus simples qu'on puisse imaginer.

Pour chaque point de l'écran, on envoie un rayon (virtuellement) et l'on teste pour chaque objet de la scene s'il se trouve sur le chemin du rayon ou pas. Le point visible est le point d'intersection le plus proche.

A partir de ce point on fait des calculs d'éclairages, avec raffinements ou pas (ombres, reflexion, lambert, phong, bump, etc..)

L'essentiel du code est dans la fonction draw.

Le balayage


Ca correspond a la boucle

 for (int y = 0; y < myScene.sizey; ++y) {
for (int x = 0; x < myScene.sizex; ++x) {

on parcourt chaque point de l'écran. et ça se termine systématiquement par l'écriture d'une valeur de couleur dans le framebuffer (ici directement sur le disque).

  imageFile.put(min(blue*255.0f,255.0f)).
            put(min(green*255.0f, 255.0f)).
            put(min(red*255.0f, 255.0f));

Il est important de noter que le min() pour chaque couleur est une approche "naive" par saturation, j'y reviendrai.

Le lancer de rayon proprement dit


On commence a initialiser le rayon en fonction du point de l'écran.

 ray viewRay = { {float(x), float(y), -10000.0f}, { 0.0f, 0.0f, 1.0f}};

ici c'est un rayon qui rentre dans l'écran et qui est perpendiculaire au point de départ. Pas de fancy projection conique, juste la bonne vieille projection orthographique; il faut bien se rendre compte que ça ne CHANGE RIEN AU CODE QUI SUIT.

Le point de départ du rayon est arbitrairement loin derriére l'observateur, l'idéal c'est qu'il n'y ait pas de clipping donc que ce point de départ dépende de la scéne. En pratique il suffit de le prendre suffisamment grand.

NOTA: le probleme est différent en projection conique, les positions derriere l'observateur sont illégales.

L'intersection la plus proche


Le rayon est orienté et paramétré par t, distance arithmétique au point de départ.

On ne se déplace que dans un seul sens, donc on prend le t de départ derriére l'écran le plus loin possible (valeur arbitraire 20000 ce qui englobera toute la scène en tenant compte du point de départ du rayon).

Le code qui détecte les collisions est réduit au minimum.

Pour chaque sphère de la scène on calcule les points d'intersection rayon/sphère qui sont au nombre de deux. On prend le plus proche et l'on compare sa distance au précédent point d'intersection trouvé (ou à la distance de clipping).

La sphère qui a le point d'intersection le plus proche est la sphère courante.


Intersection rayon sphere


C'est la plus simple des intersection (c'est la raison pour laquelle je n'ai pas inclu de cube dans la scène).

Les maths ne sont pas très intéressantes. Cela revient á résoudre une équation polynomiale du second degré (niveau première).

On calcule le delta et si le delta est inférieur à zéro il n'y a aucune solution, le rayon n'intersecte pas la sphère.

 if (D < 0.0f) return false;

Si le delta est positif on a deux distances,

 float t0 = B - sqrtf(D);
float t1 = B + sqrtf(D);

on les compare à la distance de clipping ou celle de la précédente intersection la plus proche.

Calcul de la valeur d'éclairement au point


L'idée c'est d'ajouter la contribution de chaque lumière à l'éclairement final.

Une première élimination rapide, on se débarasse des lumières qui sont du mauvais coté de la surface. (on considère les surfaces à un seul coté, le coté "sortant").

  if ( n * dist < = 0.0f )
    continue;

Ensuite on détermine si le point est dans l'ombre d'un autre objet de la scène pour cette lumière là.

Le code est similaire à la détection de collision. Le rayon part du point d'intersection avec la sphère et a pour direction le vecteur qui pointe vers la lumière. Par contre ici on se fiche de savoir a quelle distance on intersecte les autres sphères, on quitte dès qu'une des sphères est touchée par le rayon: on est dans l'ombre, cette lumière ne contribuera pas à l'éclairement total du point.

Lambert


La valeur d'éclairement de Lambert (du nom du Français qui a décrit cette équation) est égale à la quantité de lumière incidente modulo le cosinus de l'angle entre le rayon de lumière incidente et le vecteur de surface.

C'est un modèle valable pour les surfaces diffuses (l'intensité ne varie pas en fonction du point de vue, l'intensité est représentée comme un cercle sur l'image ci dessous).

On calcule le cosinus de l'angle par un simple produit scalaire entre vecteur direction du rayon lumineux et vecteur normal à la surface.

 float lambert = (lightRay.dir * n) * coef;

La reflexion


Que serait le raytracing sans une bonne vieille reflexion parfaite (bien qu'irréaliste). Chaque matériel a un coéfficient de réflexion. Si celui-ci est égal à zéro, la surface n'est pas du tout reflexive et on passe au point suivant. Sinon, on réitère tout le processus précédent mais cette fois avec un point de départ = le point de la sphère courant et la direction = la direction courante "reflechie" par le vecteur normal à la surface.

 float reflet = 2.0f * (viewRay.dir * n);
viewRay.start = newStart;
viewRay.dir = viewRay.dir - reflet * n;

Pour éviter les boucles infinies, on limite le nombre d'itérations par point à dix.

Digression : Le calcul des réflexions successives semble un candidat naturel pour un algorithme récursif mais dans la pratique on travaille par itération. Cela économise l'utilisation de la pile mais nécessite de faire quelques concessions sur la souplesse.

Les modèle utilisés ici (Lambert + reflexion parfaite) sont bidon mais peuvent être complexifiés à l'envi pour representer des matériaux réels. (Phong, Blinn, Anisotropique, BRDF..). On y reviendra.

Téléchargement du code source ici : raytrace_page1.zip Vous n'avez pas besoin de librairie particulière pour le compiler et le faire tourner, seulement un compilateur C++ livré avec la standard C++ library. Testé avec le compilateur de GCC 3.2 et visual C++ .net 2003.


Direction page 2 : "Supersampling et post processing".

 

Quick Navigation :


page 1 : "Premiers pas".
page 2 : "Matériaux spéculaires et post processing".
page 3 : "Textures".
page 4 : "Flou Fresnel et Blobs".
page 5 : "HDR loi de beer et aberration chromatique".
page 6 : "Photon mapping".

Plus d'articles et commentaires : Retour au journal.

Copyright © Grégory Massal 1976-2005

Partner websites : LEGREG | GRAPHICS | GRAPHISME | PHOTOGRAPHY | OUT OF MY MIND | ANIMATION MENTOR | GREEN LIVING | VOXEL | RAY TRACING