Flou, Fresnel et Blobs

Je rappelle que le code qui suit n'est pas particulièrement optimisé, il sert principalement d'illustration aux différentes notions qui sont abordées dans cette suite d'articles. Je suis désolé aussi pour les commentaires succincts et l'absence de "projets" (pour ceux qui ont l'habitude de travailler sur des projets avec Visual Studio) en contrepartie je m'arrange pour que tout soit compilable avec une simple ligne de commande et ne nécessite pas trop de librairies externes.

Aussi je peux paraitre sauter du coq à l'âne mais la synthèse d'image est un très vaste sujet qu'on ne peut pas couvrir avec dix pages d'articles et de code. Il s'agit donc de "browser" certains sujets suivant mon humeur et mon temps libre disponible. Les livres complets sur le sujet ne manquent pas et j'encourage les gens qui sont sérieux sur la volonté d'apprendre de compléter cette lecture par l'achat de quelques bouquins sur l'image de synthèse (et d'apprendre l'anglais accessoirement).

C'est la quatrième partie de notre série d'articles sur le raytracing en C++. Elle fait suite à la partie intitulée "Textures".

Modèle de caméra et profondeur de champ

Ce qui suit est une simplification d'un système optique tel qu'il existe dans l'oeil ou dans un appareil photographique.

Une caméra simplifiée est constituée d'un centre optique qui est le point où les rayons lumineux convergent, et d'un plan de projection. Ce plan de projection peut être placé n'importe où il peut même être situé dans la scène puisqu'il s'agit d'un calcul sur ordinateur et non pas d'un vrai appareil photo avec pellicule. Il dispose également d'un système optique qui est là pour faire converger les rayons qui passent à travers le diaphragme. Il faut en effet comprendre qu'une des grosses caractéristiques des caméras et qu'ils ont besoin de faire passer un faisceau de lumière pour que la pellicule ou la rétine soit suffisamment éclairées pour être marquées. Le système optique doit donc être configuré pour que le point d'intérêt de la scène soit net sur le plan de projection. C'est souvent mutuellement exclusif c'est à dire qu'on ne peut pas avoir deux points nets qui soient proche et lointain à la fois. En photographie, on appelle cela la profondeur de champ (depth of field). Notre modèle simplifié de caméra permet d'obtenir le même effet pour plus de réalisme (mais notre avantage par rapport aux photographes c'est que l'on peut s'en passer).

Le flou entrainé par la profondeur de champ est un peu lourd à mettre en oeuvre en terme de calcul. On va utiliser ce qu'on appelle une méthode stochastique. C'est à dire qu'on va faire intervenir du pseudo-hasard pour prendre en compte la complexité du problème. Cela consiste à imaginer que l'on suive un photon dans notre système optique et que ce photon ait une déviation aléatoire du centre optique causé par l'ouverture du diaphragme. Le système optique est une boite noire. Moins on en sait dessus mieux on se porte. Tout ce que l'on veut savoir c'est à quel distance de notre centre les points de la scène seront perçus comme nets et de quelle direction notre photon venait avant de heurter notre plan de projection afin de suivre son chemin inverse. On va utiliser les propriétés géométriques du système optique. Premièrement tout rayon passant directement par le centre optique n'est pas dévié. Deuxièmement tout rayon passant par un point du plan "net" de la scène, va converger sur un unique point du plan de projection, c'est la définition du plan "net".

En partant d'un point de projection on remarque donc que suivant le chemin suivi dans le système de lentille on va voir plusieurs points différents de la scène. On n'a pas besoin de beaucoup plus pour avoir un flou convaincant et facilement configurable (d'abord sa force et ensuite la distance où les objets apparaissent nets). Meme si après cela on pourrait avoir envie de simuler des appareillages réels avec un impact plus important sur la forme du flou créé.

vecteur dir = {(fragmentx - 0.5f * myScene.sizex) / projectionDistance,
            (fragmenty - 0.5f * myScene.sizey) / projectionDistance,
            1.0f};

float norm = dir * dir;
// je ne pense pas que ça puisse arriver mais on n'est jamais trop prudent
if (norm == 0.0f)
    break;
dir = invsqrtf(norm) * dir;
// le point de départ est toujours le centre optique de la caméra
// il peut être perturbé plus tard pour un effet de profondeur de champ
point start = {0.5f * myScene.sizex,  0.5f * myScene.sizey, 0.0f};
// le point visé est le point invariant du pixel courant,
// c'est à dire que par construction tous les rayons qui participent
// au pixel courant doivent passer par ce point
point ptVise = start + myScene.persp.clearPoint * dir;

for (int i = 0; i < myScene.complexity; ++i)
{                 
    ray viewRay = { {start.x, start.y, start.z}, {dir.x, dir.y, dir.z} };

    if (myScene.persp.dispersion != 0.0f)
    {
        vecteur vDisturbance;                       
        vDisturbance.x = (myScene.persp.dispersion / RAND_MAX) * (1.0f * rand());
        vDisturbance.y = (myScene.persp.dispersion / RAND_MAX) * (1.0f * rand());
        vDisturbance.z = 0.0f;

        viewRay.start = viewRay.start + vDisturbance;
        viewRay.dir = ptVise - viewRay.start;
       
        norm = viewRay.dir * viewRay.dir;
        if (norm == 0.0f)
            break;
        viewRay.dir = invsqrtf(norm) * viewRay.dir;
    }
    color rayResult = addRay (viewRay, myScene, context::getDefaultAir());
    fTotalWeight += 1.0f;
    temp += rayResult;
}
temp = (1.0f / fTotalWeight) * temp;

On a rajouté au fichier scène des informations de perspective. Celle qui nous intéresse c'est la dispersion dans le code ci dessus. Si la dispersion est non nulle, on prend le rayon sortant du centre optique et on cherche la position du point net qui est le point visé par le rayon sur le plan net (situé à la distance "clearPoint"). Tous les rayons passant par ce point visé arriveront par construction sur notre point de projection. Par contre leur direction diffèrent, par exemple ici on perturbe légèrement le point de départ du rayon, en le faisant pointer sur le point net on obtient la nouvelle direction. On fait ça un certain nombre de fois par pixel, nombre défini par la variable "complexity". Si l'on veut un effet de flou convaincant et sans trop de bruit dans le résultat il faut un grand nombre de rayons par pixels. Il est sans doute possible de réduire ce nombre mais on ne s'intéresse pas trop à l'optimisation pour l'instant. Le code est déjà bien assez compliqué comme ça.

La "distance" virtuelle du plan de projection est déterminée par le FOV et par la taille du viewport.

// on fait des maths ici parce que nos paramètres sont
// implicites, on donne le FOV horizontal (le champ de vision)
// en paramètre et il faut en déduire à quelle distance
// virtuelle se trouve notre plan de projection du point d'origine.
float projectionDistance = 0.0f;

if ((myScene.persp.type == perspective::conic) && myScene.persp.FOV > 0.0f && myScene.persp.FOV < 189.0f)
{
    // On triangule avec l'angle du FOV divisé par deux et
    // la taille du viewport divisé par deux
    projectionDistance = 0.5f * myScene.sizex / tanf (float(PIOVER180) * 0.5f * myScene.persp.FOV);
}


Voici le résultat quand on place le plan net à distance de la sphère du milieu:


Et voilà le résultat quand le plan net est situé à grande distance (par rapport à la taille de l'ouverture), seul le paysage est net dans ce cas :


Pour obtenir ce niveau de qualité il a fallu envoyer un très grand nombre de rayons. Il a fallu plus d'une demi heure pour calculer chaque image. C'est beaucoup et il est évidemment possible d'optimiser le nombre de rayons à envoyer. Mais cela n'est pas notre priorité pour l'instant.


Digression : les contributions de chaque rayon sont additionnées avant le calcul d'exposition et la correction gamma contrairement au calcul d'antialiasing. La raison principale c'est que le flou est le résultat de l'addition des photons avant de heurter l'oeil ou la surface photosensible. En pratique, un point très lumineux mais flou laissera donc une tache de lumière qui semblera plus importante que sa taille réelle. Ce n'est pas une découverte récente, des peintres comme Vermeer exploitait déjà ces petits détails pour améliorer le rendu de leurs peintures (Vermeer avait eu comme d'autres de son époque accès à une chambre noire, camera oscura, qui changeait la perception que l'on avait de la lumière.).


Isosurfaces (blobs)

Une isosurface est une surface qui est défini dans un champ de potentiel variable (et continu!) par une valeur de potentiel donné. On suppose ce potentiel scalaire et qui a les propriétés suivantes :

- les champs sont additifs : le champ créé par trois sources est égal au champ créé par une source multiplié par trois. En pratique dans la vie réelle, les champs magnétiques, électriques et gravitationnels sont aussi additifs c'est donc un domaine assez connu. Si on voulait représenter une surface isogravitationnelle approchée on pourrait utiliser ma méthode.

- la valeur du potentiel pour un point est défini comme l'inverse du carré de la distance à ce point. Grace à la propriété ci dessus, on a donc facilement la valeur du potentiel pour n points. Il suffit d'ajouter leur valeur.

- On peut approximer comme un cochon, vous pourriez etre en mal de différencier à l'oeil nu un blob de son approximation.

Voici une représentation d'isopotentielles en 2D. Il s'agit de trois attracteurs dans un champ gravitationel. En rouge on a tracé plusieurs isopotentielles pour des valeurs du champ différent et en bleue on a tracé la direction du gradient. Ce sont en fait les lignes de support de la force de gravitation.


Les caractéristiques de ce problème sont posées par définition. Il n'est pas la peine de chercher la justification de ces propriétés, elles sont données.

Passons maintenant à notre code : On va chercher à éviter la résolution d'équations polynomiales du nième degré qui dépasse le cadre de notre série d'article. À la place on va adopter une méthode approchée mais très suffisante pour nos scènes limitées. Chaque centre découpe l'espace en zones avec des sphères concentriques. En dehors de la sphère la plus extérieure c'est comme si le centre n'existait pas. C'est une optimisation rapide, en dehors de la zone maximale on fait comme si on ne subit l'influence que des N-1 sphères qui restent.


Voilà comment un rayon traverse notre champ de potentiel : la fonction de potentiel est nulle au départ du rayon les coefficients sont zéro, zéro, zéro. Puis il heurte la première sphère, la fonction de potentiel devient une fonction du premier degré en r^2, r étant la distance qui le sépare du centre de la première sphère et qui est une fonction linéaire de t. En remplaçant donc t dans la fonction de potentiel on obtient la variation de notre fonction de potentiel en fonction de t. On peut savoir si le rayon heurte notre isosurface en résolvant l'équation fonction de potentiel = notre seuil. C'est une équation du second degré en t donc facile à résoudre. On fait ça pour chaque nouvelle zone traversée. Une nouvelle zone est définie sur notre rayon comme l'intersection avec une nouvelle sphère qui change l'équation. On a de bonnes caractéristiques qui font que notre surface isopotentielle approchée sera continue.


Notre travail consiste donc à étiqueter chaque entrée/sortie d'une sphère d'influence de trier les points d'intersection en fonction de t, et de résoudre une à une les équations posées sur le trajet du rayon, jusqu'à trouver notre rayon d'intersection le plus proche.

for (unsigned int i= 0; i< b.centerList.size(); i++)
{
    float A, B, C;
    float fDelta, t0, t1;
    point currentPoint = b.centerList[i];

    vecteur vDist = currentPoint - r.start;
    A = 1.0f;
    B = - 2.0f * r.dir * vDist;
    C = vDist * vDist;

    // on parcourt la liste des zones d'influences de la sphère courante
    // on calcule la nouvelle version du polynome qui a cours dans
    // cette zone d'influence et on le stocke de manière incrémentale
    // ce qui importe c'est la différence avec la zone précédente, ce qui permet
    // de bien gérer le cas de sphères imbriquées les unes dans les autres
    for (int j=0; j < zoneNumber - 1; j++)
    {
        // On calcule le Delta de l'équation s'il est négatif
        // il n'y a pas de solution donc pas de point d'intersection
        fDelta = B*B - 4.0f * (C - zoneTab[j].fCoef * rSquare);
        if (fDelta < 0.0f)
            break;
        t0 =  0.5f * ( - B - sqrtf(fDelta));
        // cool on ne s'occupe pas de l'ordre il est ici explicite t0 < t1
        t1 =  0.5f * ( - B + sqrtf(fDelta));
        poly poly0 = {zoneTab[j].fGamma * A * rInvSquare ,
                        zoneTab[j].fGamma * B * rInvSquare ,
                        zoneTab[j].fGamma * C * rInvSquare + zoneTab[j].fBeta };
        poly poly1 = {- poly0.a, - poly0.b, - poly0.c};
       
        // les variations du polynome sont trièes par leur position sur le rayon
        // au fur et à mesure qu'elles sont insérées. C'est la map qui nous garantit cela.
        // ce serait peut-etre plus optimal de placer dans un vector sans se soucier de l'ordre
        // et trier ensuite mais je me fiche de l'optimisation en fait.
        polynomMap.insert(pair<float, poly>(t0, poly0));
        polynomMap.insert(pair<float, poly>(t1, poly1));
    };
}

Voici ci dessus le code qui fait le calcul d'intersection du rayon avec toutes les sphères d'influence. On peut les faire toutes une à une mais en pratique on fait pour chacune des sources (qui a dix sphères d'influences dans le code) le test d'intersection de la plus éloignée à la plus rapprochée, ainsi si une sphère éloignée n'est pas intersectée ce n'est pas la peine de chercher plus proche. S'il y a intersection, alors on a probablement deux valeurs de t qui correspondent aux deux intersections. On va placer ces valeurs de t dans une liste triée (ici une map) qui comprend en association avec des valeurs, la variation de l'expression du champ de potentiel à ces frontières. À cause de la symétrie du problème, la variation en sortie est toujours l'opposée de celle en entrée. Les variations sont exprimées en terme de coéfficients polynomiaux a, b et c.

// en dehors de toute zone d'influence le champ de potentiel est nul
poly currentPolynom = {0.0f,0.0f,0.0f};

for (map<float, poly>::const_iterator it = polynomMap.begin(); it != polynomMap.end(); ++it  )
{
    // comme on a stocké les polynomes de manière incrémentale on
    // reconstruit le polynome de la zone d'influence courante
    currentPolynom.a += (*it).second.a;
    currentPolynom.b += (*it).second.b;
    currentPolynom.c += (*it).second.c;

    map<float, poly>::const_iterator itNext = it;
    ++itNext;

    // ça ne sert à rien de résoudre l'équation si la zone d'influence est avant le point de départ
    // ou après le point d'arrivée sur le rayon
    if ((t > (*it).first ) && ((*itNext).first) > 0.01f)
    {
        // on peut se permettre de résoudre la dernière équation de manière exacte
        // après toutes les approximations que l'on a fait
        // avec un nombre suffisant de découpages,
        // il devrait être difficile de faire la distinction
        // entre le blob et son découpage approché.
        float fDelta = currentPolynom.b * currentPolynom.b
            - 4.0f * currentPolynom.a * (currentPolynom.c - 1.0f) ;
        if (fDelta < 0.0f)
            continue;

        bool retValue = false;

        float t0 = (0.5f / currentPolynom.a) * (- currentPolynom.b - sqrtf(fDelta));
        if ((t0 > 0.01f ) && (t0 >= (*it).first ) && (t0 < (*itNext).first) && (t0 <= t ))
        {
            t = t0;
            retValue = true;
        }
        float t1 = (0.5f / currentPolynom.a) * (- currentPolynom.b + sqrtf(fDelta));
        if ((t1 > 0.01f ) && (t1 >= (*it).first ) && (t1 < (*itNext).first) && (t1 <= t ))
        {
            t = t1;
            retValue = true;
        }

        if (retValue == true)
            return true;       
    }
}

return false;


Une fois que l'on a notre liste triée avec les variations de l'expression du champ, on va parcourir cette liste depuis l'extérieur jusqu'à ce qu'on trouve un point d'intersection intéressant (dans notre intervalle d'intersection possible). J'espère que le code parle de lui-même.

Une fois que l'on a trouvé le point d'intersection il reste encore la normale à la surface nécessaire pour les calculs d'éclairage et de réflexion/refraction. Vous verrez parfois suggéré d'approcher la normale comme une moyenne pondérée de normales des sphères d'influence. Mais on peut faire beaucoup mieux, comme il s'agit d'une surface d'isopotentielle, une des belles propriétés de ces surfaces est que la normale en un point est dans la direction du gradient du champ de potentiel.

Je vous épargne le calcul du gradient d'un potentiel en 1/R^2, voici le code résultant :

void blobInterpolation(point pos, const blob& b, material matList[], vecteur &vOut, material& matOut)
{
    vecteur gradient = {0.0f,0.0f,0.0f};
    // float fSomme = 0.0f;
    float fRSquare = b.size * b.size;
    for (unsigned int i= 0; i< b.centerList.size(); i++)
    {
        vecteur normal = pos - b.centerList[i];
        float fDistSquare = normal * normal;
        if (fDistSquare <= 0.001f)
            continue;
        float fDistFour = fDistSquare * fDistSquare;
        normal = (fRSquare/fDistFour) * normal;

        // c'est la vraie formule du gradient dans un champ de potentiel
        // et non pas une simple moyenne
        gradient = gradient + normal;
        // fSomme = fSomme + (fRSquare/fDistFour);
    }
    vOut = gradient;
}

Voici le résultat avec trois "sources ponctuelles" :


Fresnel et les volumes transparents

Certains matériaux ont la propriété de transmettre la lumière (le verre, l'eau) mais affectent sa progression ce qui conduit a deux phénomènes. Le premier est la réfraction, le rayon lumineux est propagé mais est dévié de sa trajectoire initiale selon une formule formulée par Descartes (ou Snell pour les anglophones).

En gros si ThetaI est l'angle d'incidence par rapport à la normale à la surface alors on a ThetaT, l'angle de refraction (angle du rayon transmis) qui est tel que n2 * sin(Thetat) = n1 * sin(ThetaI). C'est la fameuse loi de Snell-Descartes.

n est appelée indice de refraction du milieu. C'est une variable sans unité calculée en fonction de celle du vide qui par définition est égale à 1. n1 est du coté de l'incidence, n2 est du coté de la transmission. Bien évidemment la loi est entièrement symétrique (en optique classique il n'y a pas de différence entre un rayon arrivant et un rayon partant). Cela veut dire que ThetaT et ThetaI sont interchangeables.

Symétriques à une exception près. si n1 > n2, alors il y a des angles ThetaI pour lesquels il n'y a pas d'angle ThetaT solution. Au delà d'un certain angle d'incidence, il n'y a donc tout simplement pas de rayonnement transmis.

Le deuxième effet est la reflexion d'une partie de la lumière. Cette reflexion est toujours dans la direction refletée par la normale par contre son intensité varie en fonction de la quantité de lumière transmise par réfraction.


Quelle est la quantité de lumière transmise et la quantité de lumière réfléchie ? C'est là que Fresnel intervient. L'effet de Fresnel est quelque chose d'assez complexe qui fait intervenir la nature ondulatoire de la lumière. Jusqu'à présent on a traité les surfaces réfléchissantes comme de simples miroirs. Le reflet d'un miroir est du à l'effet metalique des électrons excités qui renvoient intégralement le rayon lumineux dans la direction réfléchie.

Cependant la plupart des surfaces ne sont pas totalement réflechissantes comme un miroir. La plupart du temps elles réfletent un peu la lumière à certains angles et deviennent très réfléchissantes à des angles bas. C'est le cas de matériaux comme l'eau ou le verre. Mais également de nombreux autres matériaux qui ne sont pas considérés comme "transparents". Une feuille de papier réflète une partie de la lumière incidente, celle qui arrive aux angles très bas et elle diffuse le reste (selon la formule de Lambert), de meme que le bois, la céramique, etc. La quantité de lumière réfléchie dans une direction en fonction de la direction d'incidence est appelée réflectance.

Pour une surface qui réagit à un champ électromagnétique incident il y a deux réflectances en fonction de la polarité de la lumière. La polarité c'est l'orientation dans lesquelles varient les champs électriques et magnétiques de chaque photon. On sépare les photons en orientation horizontale par rapport à la surface et les photons en orientation perpendiculaire à l'horizontale (pas nécessairement verticale). Pour une lumière non polarisée de base, il y a moitié moitié. Notre calcul se fiche de la polarité et fait une simple moyenne de la réflectance calculée pour les photons horizontaux et les photons perpendiculaires.

if(((currentMat.reflection != 0.0f) || (currentMat.refraction != 0.0f) ) && (currentMat.density != 0.0f))
{
    // material de type verre on va calculer le coefficient de reflexion de fresnel

    float fDensity1 = myContext.fRefractionCoef;
    float fDensity2;
    if (bInside)
    {
        // on considere que le rayon incident traverse un ether proche du vide (ou de l'air)
        // en théorie il faudrait d'abord calculer pour savoir si l'extérieur
        // se trouve dans un autre objet mais ça dépasse le cadre de notre code.
        fDensity2 = context::getDefaultAir().fRefractionCoef;
    }
    else
    {
        fDensity2 = currentMat.density;
    }

    // ici on tient compte du fait que le déplacement de la lumiere
    // est symetrique, de l'observateur a la source de la lumiere ou de la source de lumiere
    // a l'observateur. On fait donc les calculs du coefficient en prenant en compte le rayon provenant
    // du point d'observation.
    fCosThetaI = fabsf(fViewProjection);

    if (fCosThetaI >= 0.999f)
    {
        // cas du rayon incident selon la normale a la surface
        fReflectance = (fDensity1 - fDensity2) / (fDensity1 + fDensity2);
        fReflectance = fReflectance * fReflectance;
        fSinThetaI = 0.0f;
        fSinThetaT = 0.0f;
        fCosThetaT = 1.0f;
    }
    else
    {
        fSinThetaI = sqrtf(1 - fCosThetaI * fCosThetaI);
        // le signe de Sin ThetaI n'a aucune importance, c'est le meme que celui de Sin ThetaT
        // et ils s'annihilent mutuellement dans le calcul des coefficients de Reflexion
        fSinThetaT = (fDensity1 / fDensity2) * fSinThetaI;
        if (fSinThetaT * fSinThetaT > 0.9999f)
        {
            // On a atteint l'angle critique au dela duquel
            // la surface est entierement reflexive
            fReflectance = 1.0f ;
            fCosThetaT = 0.0f;
        }
        else
        {
            fCosThetaT = sqrtf(1 - fSinThetaT * fSinThetaT);
            // tout d'abord la reflectance de la lumiere polarisé dans le
            // plan orthogonal au plan de reflexion
            float fReflectanceOrtho = (fDensity2 * fCosThetaT - fDensity1 * fCosThetaI )
                / (fDensity2 * fCosThetaT + fDensity1  * fCosThetaI);
            fReflectanceOrtho = fReflectanceOrtho * fReflectanceOrtho;
            // Ensuite la reflectance de la lumiere polarisé dans le
            // plan parallele au plan de reflexion
            float fReflectanceParal = (fDensity1 * fCosThetaT - fDensity2 * fCosThetaI )
                / (fDensity1 * fCosThetaT + fDensity2 * fCosThetaI);
            fReflectanceParal = fReflectanceParal * fReflectanceParal;

            // le coefficient de reflectance global est égal à la moyenne
            // des deux pour une lumière de base non polarisée
            fReflectance =  0.5f * (fReflectanceOrtho + fReflectanceParal);
        }
    }
}
else
{
    // reflexion de type "metal", la reflectance est la meme dans toutes les directions
    // Les métaux sont conducteurs et induisent un changement dans la polarisation de la lumière
    // que l'on ignore royalement.
    fReflectance = 1.0f;
    fCosThetaI = 1.0f;
    fCosThetaT = 1.0f;
}

On doit modifier légèrement le code qui calcule l'éclairage et le reste. D'abord si on a un coéfficient de réflexion et de transmission tous les deux égaux à un, on se fiche du calcul d'éclairage classique, on considère que toute lumière qui arrive est soit transmise soit réflechie. Ensuite on doit décider de la suite à donner à notre algorithme de raytracing. Avant on n'avait un choix limité, soit on terminait la course, soit on réfléchissait le rayon et on continuait le raytracing à partir du point courant. Ici un troisième choix s'offre à nous qui est de continuer la course du rayon, mais cette fois ci de l'autre coté de la surface, le rayon transmis. On peut traiter cela récursivement, en utilisant la pile du C++, ou bien décider d'une méthode alternative qui est de ne suivre qu'un seul choix par rayon. Comment prendre en compte les deux effets dans ce cas ? J'ai pris le parti d'implémenter ici une méthode aléatoire dite de "roulette russe". Chaque rayon suivra une piste unique et a chaque branchement lancera un dé pour savoir quel chemin il va prendre. Le choix de la roulette russe fait que à partir d'un point de départ proche les rayons peuvent diverger fortement en intensité et donnera un effet fortement bruité à notre image. Pour éviter cet effet bruité on est censé faire plus d'un rayon par pixel pour prendre en compte les nombreuses variations de parcours. Cela tombe bien on faisait déjà cela pour rendre l'effet de flou de la profondeur de champ. On peut combiner aisément les deux.

Pour implanter cela correctement, il faut utiliser ce qu'on appelle importance sampling. C'est un principe similaire à celui qui pourrait arriver dans la réalité si l'on suivait le chemin individuel de chaque photon (et le raytracing est vraiment le calcul du chemin inverse du photon). Les coefficients calculés ci dessus, sont donc des probabilités qu'un photon décide de partir dans une direction ou dans une autre.

Transcrit en C++, cela donne:

fTransmittance = currentMat.refraction * (1.0f - fReflectance);
fReflectance = currentMat.reflection * fReflectance;

float fTotalWeight = fReflectance + fTransmittance;
bool bDiffuse = false;

if (fTotalWeight > 0.0f)
{
    float fRoulette = (1.0f / RAND_MAX) * rand();

    if (fRoulette <= fReflectance)
    {
        coef *= currentMat.reflection;

        // Le rayon viewRay est réflechi par la normale.
        // Pour calculer le rayon refléchi on fait la projection
        // du vecteur direction du rayon vue
        // sur la direction de la normale.
        // Pour avoir le vecteur tangent il faudrait retrancher
        // cette projection au vecteur direction
        // mais ici on veut la reflexion et donc il faut retrancher
        // la projection deux fois au vecteur direction.

        float fReflection = - 2.0f * fViewProjection;

        // on fait partir le nouveau rayon du point d'intersection avec la sphère courante
        // et on le dirige selon le vecteur reflexion que l'on vient de calculer
        viewRay.start = ptHitPoint;
        viewRay.dir += fReflection * vNormal;
    }
    else if(fRoulette <= fTotalWeight)
    {
        coef *= currentMat.refraction;
        float fOldRefractionCoef = myContext.fRefractionCoef;
        if (bInside)
        {
            myContext.fRefractionCoef = context::getDefaultAir().fRefractionCoef;
        }
        else
        {
            myContext.fRefractionCoef = currentMat.density;
        }

        // ici on calcule le rayon transmis avec la formule de Snell-Descartes
        viewRay.start = ptHitPoint;

        viewRay.dir = viewRay.dir + fCosThetaI * vNormal;
        viewRay.dir = (fOldRefractionCoef / myContext.fRefractionCoef) * viewRay.dir;
        viewRay.dir += (-fCosThetaT) * vNormal;
    }
    else
    {
        bDiffuse = true;
    }
}
else
{
    bDiffuse = true;
}


Comme pour la profondeur de champ, il faut envoyer un certain nombre de rayons pour éviter l'effet bruité, voilà ce que ça donne avec un blob et une densité de réfraction de 2.

Voici le résultat du programme avec les notions abordées dans cette page :

Téléchargement du code source ici : raytrace_page4.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 5 : "HDR, loi de Beer et aberration chromatique".


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