Tutoriel 21 - Lumière de type spot

Par OGLdev, traduit par DragonJoker

Introduction

Dernier tutoriel sur l'éclairage, nous y étudions les sources de lumière de type projecteur.

Contexte

Le projecteur est la troisième et dernière source de lumière que nous allons étudier (pour au moins un petit moment…). Elle est plus complexe que la lumière directionnelle et la lumière ponctuelle et emprunte beaucoup aux deux. Le projecteur a une position d'origine et un effet d'atténuation lié à l'éloignement de la cible (comme les lumières ponctuelles), de plus sa lumière pointe dans une direction spécifique (comme les lumières directionnelles). La source lumineuse projecteur ajoute un attribut unique : elle se déverse uniquement à l'intérieur d'un cône limité, qui s'élargit avec l'éloignement de la lumière par rapport à son origine. Un bon exemple de projecteur est une lampe torche. Les projecteurs sont très utiles lorsque le personnage du jeu que vous développez est en train d'explorer un donjon souterrain, ou qu'il s'échappe d'une prison.

Nous connaissons déjà tous les outils pour implémenter le projecteur. La pièce manquante est l'effet de cône de ce type de lumière. Regardons l'image suivante :

La direction de la source lumineuse est définie par la flèche qui pointe directement vers le bas. Nous voulons que notre lumière n'ait d'effet que dans l'aire délimitée par les deux lignes rouges. Le produit scalaire vient encore une fois à notre secours. Nous définissons le cône de lumière comme l'angle entre chacune des lignes rouges et la direction de la lumière (c'est-à-dire la moitié de l'angle entre les lignes rouges). Nous pouvons prendre le cosinus « C » de cet angle et effectuer un produit scalaire entre la direction de la lumière « L » et le vecteur « V » entre l'origine de la lumière et le pixel. Si le résultat du produit scalaire est plus grand que « C » (rappel : le cosinus augmente lorsque l'angle diminue), alors l'angle entre « L » et « V » est plus petit que l'angle entre « L » et les deux lignes rouges qui définissent le cône de lumière (l'angle d'ouverture). Dans ce cas, nous voulons que le pixel reçoive de la lumière. Si l'angle est plus large, le pixel ne reçoit plus de lumière de cette source. Dans l'exemple ci-dessus, un produit scalaire entre « L » et « V » va produire un résultat plus petit que le produit scalaire entre « L » et n'importe laquelle des deux lignes rouges (il est assez évident que l'angle entre « L » et « V » est plus grand que l'angle entre « L » et les lignes rouges). Par conséquent, le pixel est hors du cône lumineux et n'est donc pas illuminé par le projecteur.

Si nous continuons avec cette approche « reçoit/ne reçoit pas de lumière », tout ce que nous allons obtenir c'est un projecteur extrêmement artificiel, avec une démarcation très nette entre les zones éclairées et les zones sombres. Elle projettera un cercle parfait dans le noir (s'il n'y a pas d'autre source lumineuse). Un projecteur plus réaliste aura une lumière diminuant progressivement au niveau des bords du cercle. Nous pouvons utiliser le produit scalaire, que nous avons calculé (afin de déterminer si le pixel est éclairé ou pas), comme facteur. Nous savons déjà que le produit scalaire vaut 1 (donc éclairage maximum) lorsque les vecteurs « L » et « V » sont égaux, mais nous rencontrons maintenant l'effet indésirable de la fonction cosinus. L'angle d'ouverture ne doit pas être trop large, sinon la lumière sera diffusée trop largement et perdra l'apparence d'un projecteur. Par exemple, définissons l'angle à 20 degrés. Le cosinus de 20 degrés est 0.939, mais l'intervalle [0.939, 1.0] est trop petit pour servir de facteur. Il n'y a pas assez d'écart et les résultats de l'interpolation ne seront pas perceptibles par l'œil. L'intervalle [0, 1] fournit de bien meilleurs résultats.

L'approche que nous allons utiliser est de faire correspondre l'intervalle plus petit, défini par l'angle d'ouverture, à l'intervalle plus large [0, 1]. Voici comment nous allons procéder :

\begin{equation*} alpha = angle\ d'ouverture\\ cos(alpha) = X\\ Nous\ voulons\ mapper\ l'intervalle\ [X, 1.0]\ sur\ [0, 1]\\ Si\ X \le X_1 \le 1.0,\ alors\ la\ valeur\ mappée\ de\ X_1\ (appelée\ X_m)\ est\ calculée\ comme\ suit:\\ d = \frac {1.0}{1.0 - X}\\ X_m = 1.0 - (1.0 - X_1) \times d\\ Vérifions\ les\ bornes\ de\ [X, 1.0]\\ X_m = 1.0 - (1.0 - X) \times d = 1.0 - (1.0 - X) * (\frac {1.0}{1.0 - X}) = 0\\ X_m = 1.0 - (1.0 - 1.0) \times d = 1.0 - 0 \times (\frac {1.0}{1.0 - X}) = 1\\ \end{equation*}

Le principe est très simple : on calcule le ratio entre l'intervalle le plus petit et l'intervalle le plus grand, puis on multiplie l'intervalle par ce ratio.

Explication du code

struct SpotLight : public PointLight
{
	Vector3f Direction;
	float Cutoff;

	SpotLight()
	{
		Direction = Vector3f(0.0f, 0.0f, 0.0f);
		Cutoff = 0.0f;
	}
};

La structure définissant le projecteur est dérivée de PointLight et ajoute les deux attributs qui la différencient d'une lumière ponctuelle : un vecteur direction et une valeur d'arrêt. La valeur d'arrêt correspond à l'angle maximum entre la direction de la lumière et le vecteur entre la source et le pixel, pour les pixels sous l'éclairage du projecteur. Le projecteur n'a pas d'effet au-delà de la valeur d'arrêt. Nous avons aussi ajouté un tableau de positions pour le shader, à la classe LightingTechnique (non décrit ici). Ce tableau nous permet d'accéder au tableau de projecteurs dans le shader.

struct SpotLight
{
    struct PointLight Base;
    vec3 Direction;
    float Cutoff;
};
...
uniform int gNumSpotLights;
...
uniform SpotLight gSpotLights[MAX_SPOT_LIGHTS];

Une structure similaire est ajoutée en GLSL, pour les lumières de type projecteur. Comme nous ne pouvons pas utiliser l'héritage comme en C++, nous utilisons une structure PointLight en tant que membre et ajoutons les nouveaux attributs. La différence importante ici est que dans le code C++, la valeur d'arrêt est l'angle lui-même, alors que dans le shader c'est le cosinus de cet angle. Le shader n'a besoin que du cosinus, donc il est plus efficace de le calculer une fois et pas pour chaque pixel. Nous définissons aussi un tableau de projecteurs et utilisons un compteur nommé « gNumSpotLights »pour permettre à l'application de définir le nombre de projecteurs réellement utilisés.

vec4 CalcPointLight(struct PointLight l, vec3 Normal)
{
	vec3 LightDirection = WorldPos0 - l.Position;
	float Distance = length(LightDirection);
	LightDirection = normalize(LightDirection);

	vec4 Color = CalcLightInternal(l.Base, LightDirection, Normal);
	float Attenuation = l.Atten.Constant +
		l.Atten.Linear * Distance +
		l.Atten.Exp * Distance * Distance;

	return Color / Attenuation;
}

La fonction de lumière ponctuelle a subi une légère modification : elle prend maintenant une structure PointLight en paramètre, plutôt que l'accès direct au tableau global. Cela permet de partager facilement cette fonction avec les projecteurs. À part ça, pas de changement ici.

vec4 CalcSpotLight(struct SpotLight l, vec3 Normal)
{
    vec3 LightToPixel = normalize(WorldPos0 - l.Base.Position);
    float SpotFactor = dot(LightToPixel, l.Direction);

    if (SpotFactor > l.Cutoff) {
        vec4 Color = CalcPointLight(l.Base, Normal);
        return Color * (1.0 - (1.0 - SpotFactor) * 1.0/(1.0 - l.Cutoff));
    }
    else {
        return vec4(0,0,0,0);
    }
}

Ici nous calculons l'effet du projecteur. Nous commençons en récupérant le vecteur entre la source lumineuse et le pixel. Comme c'est souvent le cas, nous le normalisons afin de le préparer pour le produit scalaire qui suit. Nous calculons donc le produit scalaire entre ce vecteur et la direction de la lumière (qui a déjà été normalisée par l'application), et récupérons le cosinus de l'angle entre eux deux. Ensuite, nous le comparons à la valeur d'arrêt. Celle-ci représente le cosinus de l'angle entre la direction de la lumière et le vecteur qui définit le cercle d'influence du projecteur. Si le cosinus est plus petit, cela signifie que l'angle entre la direction de la lumière et le vecteur de la source au pixel place le vecteur hors du cercle d'influence. Dans ce cas, la contribution de ce projecteur vaut zéro. Cela limitera le projecteur à un cercle plus ou moins large, dépendant de la valeur d'arrêt. Sinon, nous calculons la couleur de base comme si la lumière était une lumière ponctuelle. Puis, nous récupérons le produit scalaire que nous avons calculé précédemment (« SpotFactor ») et l'introduisons dans la formule calculée plus haut. Cela fournit le facteur qui va interpoler linéairement « SpotFactor » entre 0 et 1. Nous le multiplions par la couleur de la lumière ponctuelle et obtenons la couleur finale du projecteur.

...
for (int i = 0 ; i < gNumSpotLights ; i++) {
    TotalLight += CalcSpotLight(gSpotLights[i], Normal);
}
...

De manière similaire aux lumières ponctuelles, nous avons, dans le programme principal, une boucle qui accumule la contribution de tous les projecteurs à la couleur finale du pixel.

void LightingTechnique::SetSpotLights(unsigned int NumLights, const SpotLight* pLights)
{
    glUniform1i(m_numSpotLightsLocation, NumLights);

    for (unsigned int i = 0 ; i < NumLights ; i++) {
        glUniform3f(m_spotLightsLocation[i].Color, pLights[i].Color.x, pLights[i].Color.y, pLights[i].Color.z);
        glUniform1f(m_spotLightsLocation[i].AmbientIntensity, pLights[i].AmbientIntensity);
        glUniform1f(m_spotLightsLocation[i].DiffuseIntensity, pLights[i].DiffuseIntensity);
        glUniform3f(m_spotLightsLocation[i].Position, pLights[i].Position.x, pLights[i].Position.y, pLights[i].Position.z);
        Vector3f Direction = pLights[i].Direction;
        Direction.Normalize();
        glUniform3f(m_spotLightsLocation[i].Direction, Direction.x, Direction.y, Direction.z);
        glUniform1f(m_spotLightsLocation[i].Cutoff, cosf(ToRadian(pLights[i].Cutoff)));
        glUniform1f(m_spotLightsLocation[i].Atten.Constant, pLights[i].Attenuation.Constant);
        glUniform1f(m_spotLightsLocation[i].Atten.Linear, pLights[i].Attenuation.Linear);
        glUniform1f(m_spotLightsLocation[i].Atten.Exp, pLights[i].Attenuation.Exp);
    }
}

Cette fonction met à jour le tableau de SpotLight dans le shader. C'est la même fonction que pour les lumières ponctuelles, avec deux choses en plus. Le vecteur direction de la lumière est aussi donné au shader, après normalisation. La valeur d'arrêt est aussi fournie en tant qu'angle par l'appelant, mais son cosinus est donné au shader (ce qui permet au shader de comparer le produit scalaire à cette valeur, directement). Notons que la fonction cosf() prend comme paramètre un angle en radians donc nous utilisons la macro ToRadian pour le convertir.

Remerciements

Merci à Etay Meiri de me permettre de traduire ses tutoriels.

Résultat :
resultat

Article d'origine