Mappage de Sphère

A propos du code

Le code dans cet article a été écrit avec Code::Blocks et la SDL 2.
Vous pouvez trouver ici un guide pour installer ces logiciels.
Bien qu'il soit basé sur la SDL, je n'utilise pas ses fonctions directement. J'ai écrit une petite bibliothèque
avec quelques fonctions basiques pour faciliter la compréhension et la portabilité dans un autre langage.
Vous pouvez en apprendre plus sur cette bibliothèque ici.

Dans cet article j'ai utilisé les fonctions pour le format Targa que j'ai présentées ici.

Dessiner la Terre

Dans cet article on va texturer une sphère et l'animer mais totalement en 2D.
L'astuce est de pré-calculer une table qui contient les coordonnées de la texture pour chaque pixel de l'écran.


La texture qu'on va utiliser est une projection équivalente cylindrique de Lambert.
Ca veut dire que vous pouvez l'enrouler autour de la sphère en forme de cylindre. Et chaque point de la sphère est
projeté sur le cylindre suivant une ligne horizontale.


En fait nous allons utiliser des images de cette page Wikipédia pour afficher la Terre.
Tout au long de cet article on utilisera les dimensions de cette texture, alors on va les appeler imageWidth et
imageHeight.
Maintenant la partie la plus difficile de ce problème c'est de calculer les valeurs dans le tableau de coordonnées.

Projection sur l'écran

Maintenant imaginez la sphère en 3 dimensions.
Dans cette image j'ai dessiné quelques parallèles comme repère.


On va la regarder avec nos yeux dans le plan de l'équateur.
On utilisera une projection orthographique, donc il n'y aura pas de déformation dues à la perspective, et on ne
verra qu'une moitié de la sphère.
Avec ces règles les parallèles vont apparaitre comme des lignes sur l'écran.


Comme la texture enroulée en cylindre a la même hauteur que la sphère, l'image à l'écran aura la même hauteur de
imageHeight.
Comme l'image à l'écran est un cercle, sa largeur sera imageHeight aussi.
Et le tableau de coordonnées aura les mêmes dimensions, parce que chaque coordonnée correspond à un pixel à l'écran.

Coordonnées sur une sphère

Sur Terre on définit notre position avec une latitude et une longitude qui sont 2 angles.
Mais d'autre part, quand on remplit notre tableau de coordonnées, on va boucler suivant x et y.
Pour trouver les coordonnées dans la texture on va devoir calculer la latitude et la longitude de chaque pixel à
l'écran.


Mais avant d'aller plus loin, on va adapter nos coordonnées.
Quand vous pensez à une image à l'écran, les coordonnées commencent en haut à gauche et augmentent vers la droite
et vers le bas.
Pour notre image carrée, les coordonnées x et y vont varier entre 0 et imageHeight.
Mais quand vous travaillez avec des cercles ou des sphères c'est plus facile de positionner les points par rapport
à leurs centres.
Alors on va décaler nos coordonnées pour qu'elles aillent entre -imageHeight/2 et imageHeight/2.


Calculer la latitude

Trouver la latitude d'un point est assez facile.
La sphère va apparaitre à l'écran comme un cercle de rayon imageHeight/2.
Si vous regardez un point sur le coté de ce cercle, vous verrez qu'il apparaît dans un triangle où l'on connait 2
cotés:
Le rayon qui est le coté le plus long et la coordonnée y.
Si vous avez lu mon article sur les cercles vous comprendrez instantanément que:
	y = radius * sin(latitude);
				

De cette formule, on peut déduire que:
	sineLatitude = sin(latitude) = y / radius;
				
et
	latitude = asin(sineLatitude);
				

Calculer la longitude

Comme on a trouvé la latitude à partir de la coordonnée y, on va trouver la longitude à partir de la coordonnée x.
Mais ici on a un petit problème. Les valeur maximum et minimum de x varient avec la latitude.
Si vous regardez cette image vous verrez que ces limites dépendent de la parallèle où on est.


Si on est à l'équateur, on connait le rayon de la parallèle, mais si on est à une autre latitude on va devoir le
calculer.
Maintenant on peut dessiner une figure qui ressemble à celle qu'on avait pour la latitude, sauf que cette fois on
regarde la coordonnée horizontale.
De cette image on peut déduire que:
	circleRadius = radius * cos(latitude)
				


Maintenant qu'on connait le rayon de la parallèle où notre point est, faisons tout tourner de façon à ce qu'on
regarde le pôle nord de la sphère.
Dans cette position, l'écran est une ligne en bas de la figure.
On voit maintenant notre parallèle comme un cercle dont on a vient de calculer le rayon.
La coordonnée x nous donne un point sur ce cercle, et en observant cette figure on obtient une autre équation
contenant la longitude.


Donc on a:
	x = circleRadius * sin(longitude)
				
De ça on peut déduire:
	sineLongitude = sin(longitude) = x / circleRadius
				
et
	longitude = asin(sineLongitude)
				

Calculer les coordonnées dans la texture

Maintenant pour chaque pixel à l'écran on connait ses coordonnées x et y et sa latitude et sa longitude sur la
sphère.
Avec toutes ces données on doit maintenant trouver les coordonnées (x, y) correspondantes dans la texture.

Regardons à nouveau la sphère par dessus, avec la texture enroulée en cylindre.


La longitude qu'on a calculée va de -π/2 à π/2. Comme on n'aime pas les coordonnées négatives, on va lui
ajouter π/2. Donc elle va aller de 0 à π. On voit sur la figure que cet angle correspond à la coordonnée x de la texture.
Mais comme on ne voit qu'une moitié de la sphère, on peut en déduire que:
	textureX = (longitude * imageWidth / 2) / M_PI
				
Maintenant pour la coordonnée y, regardons à nouveau cette figure qu'on a vue au début:


On voit que chaque point de la sphère est projeté en suivant une ligne horizontale et que la hauteur de la texture
est la même que la hauteur de la sphère.
Donc la coordonnée y de la texture va simplement être la coordonnée y du pixel:
	textureY = y
				

Programmons enfin

Maintenant que vous avez lu ces longues explications, codons ça.
On va commencer par déclarer imageWidth et imageHeight.
		#include <stdio.h>
		#include <stdlib.h>
		#include "main.h"
		#include "Graphics.h"
		#include "System.h"
		#include "math.h"
		#include "formats/TGA.h"

		int imageWidth;
		int imageHeight;
				
La table de coordonnées sera appelée mapPos.
C'est un tableau de structures qui contiennent une coordonnée x et une coordonnée y.
		struct SMapPos
		{
			unsigned short int  x, y;
		};

		SMapPos* mapPos;
				
La fonction initMapPos est là où l'on remplit ce tableau.
On commence par allouer de la mémoire pour celui-ci.
		void initMapPos()
		{
			mapPos = (SMapPos *)malloc(imageHeight * imageHeight * sizeof(SMapPos));

			float radius = (float)imageHeight / 2;
				
Puis on boucle sur chaque pixel, et on récupère un pointeur sur la cellule courante du tableau.
			for (int y = 0; y < imageHeight; y++)
				for (int x = 0; x < imageHeight; x++)
				{
					SMapPos* pos = &mapPos[y * imageHeight + x];
				
Ici, on va appliquer les formules qu'on a trouvées pour calculer la latitude et la longitude du pixel courant.
					float centeredX = (float)x - radius;
					float centeredY = (float)y - radius;

					float sineLatitude = centeredY / radius;
					float latitude = asin(sineLatitude);

					float circleRadius = radius * cos(latitude);
					float sineLongitude = centeredX / circleRadius;
				
Juste avant de calculer la longitude, on va tester son sinus pour voir si le pixel est à l'intérieur du cercle qui
représente la sphère à l'écran.
					// inside circle ?
					if (sineLongitude >= -1.0f && sineLongitude <= 1.0f)
					{
						float longitude = asin(sineLongitude) + M_PI / 2;
				
Maintenant on calcule les coordonnées de la texture à partir de x, y, latitude et longitude, et on les stocke dans
la cellule courante de mapPos.
						pos->x = (longitude * imageWidth / 2) / M_PI;
						pos->y = y;
					}
				
Si le pixel n'était pas à l'intérieur du cercle, on écrit simplement les coordonnées (0, 0).
					else
					{
						pos->x = 0;
						pos->y = 0;
					}
				}
		}
				
Dans la fonction main, on commence par charger la texture et initialiser l'écran avec ses dimensions.
		int main(int argc, char* argv[])
		{
			// init the window
			Color* image = tga.load("earth.tga", &imageWidth, &imageHeight);

			gfx.init("Earth", imageHeight, imageHeight);
			gfx.init2D();
			gfx.clearScreen(Color(0, 0, 0, SDL_ALPHA_OPAQUE));
				
Puis on remplit mapPos. Comme on l'a dit, c'est un tableau pré-caclulé, donc on n'a besoin d'appeler initMapPos
qu'une seule fois.
			// initialise the table
			initMapPos();
				
Dans la boucle principale, pour dessiner la sphère, on boucle sur chaque pixel, on récupère les coordonnées de la
texture dans mapPos, et on dessine le pixel de la texture à l'écran.
			while (sys.isQuitRequested() == false)
			{
				// draw the sphere
				for (int y = 0; y < imageHeight; ++y)
					for (int x = 0; x < imageHeight; ++x)
					{
						SMapPos* pos = &mapPos[y * imageHeight + x];
						int px = pos->x;
						int py =  pos->y;
						Color col = image[py * imageWidth + px];

						gfx.setPixel(x, y, col);
					}
				
Puis, comme d'habitude, on rend l'écran, on gère les évènements et on quitte à la fin.
				// render and handle events
				gfx.render();
				sys.processEvents();
				sys.wait(16);
			}

			gfx.quit();
			delete[] image;
			free(mapPos);

			return EXIT_SUCCESS;
		}

		Télécharger le code source
		Télécharger l'exécutable pour Windows
				
Pour tester si nos calculs sont bons, j'ai utilisé cette image de la page Wikipédia:

By Eric Gaba (Sting - fr:Sting) - Own workData : U.S. NGDC World Coast Line (public domain), CC BY-SA 4.0, Link

Les ellipses oranges sont les indicatrices de Tissot qui montrent les distorsions dues à la projection de la Terre
sur la carte.
Si nos calculs sont bons, ils devraient apparaitre comme des cercles, tous de la même taille, sur la surface de la
sphère.
Et ça a l'air de marcher.


Animation

Maintenant pour vérifier que nos calculs sont bons tout autour de la sphère, on va la faire tourner.
La beauté de la technique qu'on a utilisée est qu'on n'a pas besoin de recalculer les coordonnées de la table pour
chaque angle de rotation.
On va seulement "décaler" la position x de la texture.
Pour faire ça on a besoin d'une variable scroll qu'on va initialiser en dehors de la boucle principale.
			// initialise the table
			initMapPos();
			int scroll = 0;

			while (sys.isQuitRequested() == false)
				
Puis, quand on dessine la sphère, on va ajouter cette variable à la coordonnée x qu'on a lue dans le tableau.
Le "% imageWidth" est là pour s'assurer que le résultat reste entre 0 et imageWidth-1.
			SMapPos* pos = &mapPos[y * imageHeight + x];
			int px = (pos->x + scroll) % imageWidth;
			int py =  pos->y;
				
Et finalement, dans la boucle principale, on a besoin de la décrémenter pour faire tourner la sphère dans le bon
sens.
Si elle est négative, on lui ajoute imageWidth, encore une fois c'est pour rester entre 0 et imageWidth-1.
			scroll--;
			if (scroll < 0)
				scroll += imageWidth;

			// render and handle events

		Télécharger le code source
		Télécharger l'exécutable pour Windows
				
Le fond flashe un peu parce qu'il suit la première ligne de la texture.
Souvenez-vous qu'on avait mis les coordonnées (0, 0) dans la table pour les points en dehors du cercle, mais
maintenant on leur ajoute scroll.
Quoi qu'il en soit on peut voir que les cercles oranges ont toujours la même taille pendant toute la rotation.

Embellissement

Maintenant pour quelque chose d'un peu plus réaliste, utilisons une autre image de la page Wikipédia:

By Uwe Dedering - Own work, CC BY-SA 3.0, Link

		Télécharger le code source
		Télécharger l'exécutable pour Windows
				
Et voici le résultat:


Vous pouvez trouver qu'on ne ressent pas vraiment le volume de la Terre. Alors essayons d'ajouter des fuseaux
horaires pour voir à quoi ça ressemble.
Dans la boucle principale, juste avant qu'on dessine le pixel, on va ajouter ce code:
		const float hour = imageWidth / 24.0f;
		int timezone = px / hour;
		if (timezone % 2)
		{
			col.r *= 0.5;
			col.g *= 0.8;
		}

		gfx.setPixel(x, y, col);

		Télécharger le code source
		Télécharger l'exécutable pour Windows
				
On divise la coordonnée x de la texture par 1/24ième de sa largeur, et si le résultat est impair, on modifie
légèrement la couleur.
Maintenant la Terre ressemble un peu à un ballon de plage.


Maintenant au lieu des fuseaux horaires, affichons une ombre sur une partie de la Terre.
A la place du précédent code, avant de dessiner le pixel, on va écrire ça:
		const int shadow = (imageWidth * 1 / 3);

		if (pos->x > shadow)
		{
			col.r = col.r / (1 + (pos->x - shadow) / 8.0f);
			col.g = col.g / (1 + (pos->x - shadow) / 8.0f);
			col.b = col.b / (1 + (pos->x - shadow) / 8.0f);
		}

		gfx.setPixel(x, y, col);

		Télécharger le code source
		Télécharger l'exécutable pour Windows
				
La variable shadow est la position où l'ombre commence.
Les calculs à l'intérieur du "if" sont là pour faire un dégradé plutôt qu'une ombre abrupte.
Remarquez qu'on utilise pos->x au lieu de px, donc on ne prend pas en compte la variable scroll et l'ombre ne
tournera pas en même temps que la Terre.
Et ça ressemble à ça:


Enfin cette ombre n'est pas vraiment réaliste parce qu'elle ne prend pas en compte l'inclinaison de l'axe de
rotation de la Terre...

Liens

Vidéo des programmes de cet article