Raytracer minimaliste en Haskell : Gestion des lumières


précédentsommairesuivant

I. Introduction

Cet article fait suite à l'affichage d'une sphère. Nous allons repartir de ces sources pour ajouter des sources de lumières, des ombres, et de l'anti-aliasing.

Vous pouvez trouver les sources de cet article en cliquant sur ce lien.

II. Lumières : Révision du calcul de luminosité

II.1. La lumière ambiante

Nous allons maintenant introduire les sources de lumières. Il y a diverses sources possibles : projecteurs, boules de feu, lumière éloignée (soleil)... On peut aussi souhaiter que la lumière décroisse lorsque l'on s'éloigne de la source.

Nous allons, de façon tout à fait arbitraire, introduire les sources lumineuses suivantes :

  • Une lumière ambiante, qui sera appliquée à tous les objets. On fait le choix que la partie réfléchie reçue sera multipliée par le coefficient kitxmlcodeinlinelatexdvp\frac{1}{ad^2 + bd + c}finkitxmlcodeinlinelatexdvp où les coefficients a, b et c pourront être librement choisis, et d est la distance de l'observateur à l'objet. Cela signifie que plus l'observateur sera loin, moins la luminosité sera intense. Une lumière ambiante n'a pas de position précise dans l'espace, donc pas de coordonnées.
  • Les lumières directionnelles. Tous les rayons sont parallèles, et « proviennent de l'infini ». C'est une bonne approximation des rayons du soleil. On dira qu'une telle lumière ne s'atténue pas. Une lumière directionnelle n'a pas non plus de position dans l'espace.
  • Les spots, ou projecteurs. Ce sont des sources de lumières directionnelles en forme de cône. Plus on est dans l'axe, plus la zone est éclairée. L'angle du cône de lumière sera nécessairement aigu (afin de rendre l'implémentation plus simple). On disposera de trois coefficients a, b et c pour multiplier le résultat par kitxmlcodeinlinelatexdvp\frac{1}{ae^2 + be + c}finkitxmlcodeinlinelatexdvp où e est la distance du projecteur à l'objet.
  • Les sources omnidirectionnelles. Ce sont des sortes de boules de lumière qui éclairent dans toutes les directions. À nouveau, ces lumières disposeront d'une atténuation fonction de leur distance.

Vous remarquerez que pour toutes les lumières disposant de trois coefficients (a, b, c) pour contrôler l'atténuation, le choix (0, 0, 1) nous donne une lumière non atténuée.

Tout comme pour nos objets, une lumière sera constituée de ses caractéristiques, et d'une fonction de calcul de luminosité. Mais puisque la seule caractéristique commune à nos lumières est leur couleur, et que l'on n'en a besoin que pour calculer la luminosité, on n'a pas besoin de la stocker.

Lights.hs
Sélectionnez
module Lights (
  Light (Light),
  Attenuation,
  computeAttenuation,
  buildAmbiantLight,
  buildDirectionalLight,
  buildOmnidirectionalLight,
  buildSpotLight,
  ) where

import Tools
import Objects

data Light = Light (Intersection -> Scene -> Color)

-- Les facteurs a, b et c de l'aténuation, qui est un polynome en d
type AConstant = RealRep
type ALinear = RealRep
type ASquare = RealRep

-- Le triplet (a, b, c) utilisé pour calculer 1/(ad^2 + b*d + c)
type Attenuation = (ASquare, ALinear, AConstant)

computeAttenuation :: Attenuation -> Distance -> RealRep
computeAttenuation (a, b, c) d = 1 / (a * d ^ 2 + b * d + c)

buildAmbiantLight = undefined
buildDirectionalLight = undefined
buildOmnidirectionalLight = undefined
buildSpotLight = undefined

Toutes les fonctions exportées serviront à construire les différents types de lumières listés quelques paragraphes plus tôt. Commençons par le plus simple : la lumière ambiante.

Il nous faut pouvoir multiplier deux à deux les coordonnées d'un vecteur. On rajoute donc une fonction utilitaire.

Tools.hs
Sélectionnez
vectorProduct :: Vector -> Vector -> Vector
vectorProduct (a, b, c) (x, y, z) = (a * x, b * y, c * z)

On calcule maintenant la lumière ambiante réfléchie au niveau de l'intersection.

Lights.hs
Sélectionnez
buildAmbiantLight :: Attenuation -> Color -> Light
buildAmbiantLight attenuation lightColor = Light lightFunction
  where
    lightFunction intersection scene = (computeAttenuation attenuation distance) *! light
      where
        light = lightColor `vectorProduct` objectColor
        Material objectColor = material
        (_, distance, Object _ material _) = intersection

Afin de pouvoir tester petit à petit nos lumières, et les voir naître sous nos yeux, on va maintenant mettre en place l'application des lumières aux objets de la scène avant de poursuivre.

II.2. Calcul de la lumière

Nous nous retrouvons confrontés à un souci de dépendances cycliques. On voudrait le type objet dans Object.hs, le type Light dans Light.hs, et le type Scene dans Raytracer.hs. Seulement Scene dépend de Light et Object. Comme ghc ne supporte pas les dépendances cycliques, nous avons deux solutions. L'une utilise un système de « bootstraping », l'autre consiste à placer tous les types dans un Type.hs. Pour des raisons de simplicité et maintenabilité nous choisissons la seconde.

Types.hs
Sélectionnez
module Types (
  Red,
  Green,
  Blue,
  Color,
  Camera (Camera),
  Scene (Scene),
  Intersection,
  Object (Object),
  Light (Light),
  Material (Material),
  ) where

import Tools

--Représentation de l'observateur
data Camera = Camera Location Distance PlanSize

--Une scène
data Scene = Scene Camera [Object]

--Une intersection
type Intersection = (Location, Distance, Object)

--On object raytracable
data Object = Object Location Material (Ray -> Scene -> [Distance])

--Une lumière
data Light = Light (Intersection -> Scene -> Color)

--Couleur, 0.0 signifie "pas de couleur" et 1.0 "toute la couleur".
type Red = RealRep
type Green = RealRep
type Blue = RealRep

type Color = (Red, Green, Blue)

-- Une matière
data Material = Material Color

Nous ajoutons maintenant une liste de lumières :

Types.hs
Sélectionnez
data Scene = Scene Camera [Object] [Light]

On met à jour les diverses apparitions du constructeur Scene, en ajoutant un troisième argument (_ s'il s'agit de décomposer une scène, ou [] s‘il s'agit de construire une scène). Dans computeItersection, on doit récupérer les lumières pour construire scene.

Raytracer.hs
Sélectionnez
computeIntersection ray object scene =  -- ...
    -- ...
    Scene camera objects lights = scene
    -- ...
    scene' = Scene camera' objects lights

Il nous faut maintenant revoir computeColor pour utiliser cette liste de lumières. On commence par ajouter une fonction pour sommer les couleurs provenant d'une liste de couleurs. N'oublions pas de l'exporter. Comme on se doute que l'on va devoir sommer beaucoup de valeurs, on ne veut pas construire une liste de thunk qui seront longs à évaluer, et l'on préfère donc utiliser foldl' plutôt que foldl.

Tools.hs
Sélectionnez
import Data.List (foldl')
-- ...
vectorSum :: [Vector] -> Vector
vectorSum = foldl' (+!) (0, 0, 0)

On revoit maintenant computeColor pour qu'elle calcule chacune des lumières sur l'intersection, puis somme les couleurs produites. Cela implique de changer son type.

Raytracer.hs
Sélectionnez
--Construit la couleur à partir d'une intersection
computeColor :: Intersection -> Scene -> Color
computeColor intersection scene = color
  where
    colors = map (\ (Light f) -> f intersection scene) lights
    color = vectorSum colors
    Scene _ _ lights = scene

Il nous faut donc modifier les appels. Par chance, il n'y en a qu'un.

Raytracer.hs
Sélectionnez
getPixel :: Scene -> PixelLocation -> [Word8]
getPixel scene pixelLocation =  case color of
                                     Nothing -> [0, 0, 0, 255]
                                     Just pixels -> pixels
  where
    intersection = return pixelLocation
                   >>= computeRay
                   >>= computeIntersections
                   >>= getClosestIntersection
    color = fmap intersectionToPixel (intersection scene)
    intersectionToPixel intersection = colorToPixel . computeColor intersection $ scene
    Scene _ _ lights = scene

Si l'on lance maintenant notre raytracer, on n'obtient qu'un écran noir. C'est que l'on doit ajouter des lumières à notre scène pour y voir quelque chose. On importe Lights dans Main.hs, et on modifie donc dans tableData la ligne suivante :

Main.hs
Sélectionnez
scene = Scene camera objects [buildAmbiantLight (0, 1, 0) (1, 1, 1)]

On retrouve donc exactement la lumière que l'on avait dans l'article précédent (car kitxmlcodeinlinelatexdvpa = 0finkitxmlcodeinlinelatexdvp, kitxmlcodeinlinelatexdvpb = 1finkitxmlcodeinlinelatexdvp, kitxmlcodeinlinelatexdvpc = 0finkitxmlcodeinlinelatexdvp, donc on multiplie par kitxmlcodeinlinelatexdvp\frac{1}{0.d^2+1.d+0) = \frac{1}{d}finkitxmlcodeinlinelatexdvp).

II.3. Exposition

On peut s'amuser à ajouter plus de lumières. Ajoutons un effet torche avec une lumière ambiante jaune (1, 1, 0) ainsi qu'une lumière ambiante blanche (1, 1, 1) "plate" sur une sphère blanche.

Main.hs
Sélectionnez
    lights = [buildAmbiantLight (0, 0, 1) (1, 1, 1), buildAmbiantLight (0, 1, 0) (1, 1, 0)]
    objects = [buildSphere 1.0 (0, 0, -2) (Material (1.0, 1.0, 1.0))]
    scene = Scene camera objects lights
Mauvaises couleurs

Nom d'un phoque ! D'où sort ce bleu ? C'est bien sur un overflow dû au fait que l'on multiplie 255 par une valeur supérieure à 1 dans colorToPixel, elle-même appelée par getPixel. En fait, on voulait garantir que computeColor ne produise jamais des valeurs supérieures à 1.

Il y a deux solutions simples pour gérer cette situation. D'abord, on pourrait diviser par la somme de toutes les lumières de la scène. Ça fonctionnerait, mais on obtiendrait une image très sombre, surtout dans les zones où seulement une partie des lumières participent. Une autre solution, simple et efficace, est d'appliquer une fonction avec une courbe écrasée et tendant vers 1 en plus l'infini. Il faut aussi qu'elle soit linéaire vers les valeurs proches de 0, et qu'elle vaille 0 en 0 (en d'autres termes, on veut qu'elle soit équivalente à kitxmlcodeinlinelatexdvp\lambda*xfinkitxmlcodeinlinelatexdvp en 0). Une fonction qui répond à nos attentes est kitxmlcodeinlinelatexdvp1 - e^{-\lambda*x}finkitxmlcodeinlinelatexdvp. Le choix de lambda dépend des scènes, donc on laissera cette information dans la caméra, sous le nom "d'exposition".

On rajoute donc le champ dans le type Camera

Types.hs
Sélectionnez
--Représentation de l'observateur
data Camera = Camera Location Distance PlanSize Exposition

--L'exposition
type Exposition = RealRep

On s'occupe de modifier tous les appels au constructeur de Camera. Dans buildDefaultCamera, on mettra une exposition de 1. Ensuite, on peut revoir la fonction computeColor.

Types.hs
Sélectionnez
--Construit la couleur à partir d'une intersection
computeColor :: Intersection -> Scene -> Color
computeColor intersection scene = vectorApply (\x -> 1 - exp(-exposition*x)) color
  where
    colors = map (\ (Light f) -> f intersection scene) lights
    color = vectorSum colors
    Scene camera _ lights = scene
    Camera _ _ _ exposition = camera

Le résultat obtenu est bien plus convainquant :

Bonnes couleurs

précédentsommairesuivant

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2013 Zenol. 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. Droits de diffusion permanents accordés à Developpez LLC.