Texture et bruit de Voronoï

Alors, Texture de Voronoï... Qu'est-ce que c'est?

En général ça se présente sous la forme d'une heightmap construite sur la base d'un diagramme de Voronoï.

Il s’agit d’un objet mathématique découpant l’espace en cellules de manière particulière. On parle de pavage de Voronoï, ou de Voronoï tesselation en anglais.

C

Cet objet mathématique est définit ainsi :

Soit le plan affine R² et soit S, un ensemble fini de points du plan.

  • - On appelle germes, graines (seed, en anglais) ou bien encore sites les points de cet ensemble S.
  • - On appelle région ou cellule (cell, en anglais) de Voronoï, associée à un élément p de S, l’ensemble de TOUS les points qui sont plus proches de p que de tout autre point de S. Ici, les éléments de S sont des points de références et on évalue les distances entre ceux là et tous les points qui existent sur le plan. Parmi tous les points d’une cellule de Voronoï V, il ne peut y avoir qu’un seul élément de S : Celui qui a servi à construire V.

À titre de rappel un plan affine est un plan dans lequel il n’y aurait pas de repère. En effet dans ce plan nous ne nous intéressons pas aux positions des points mais à la distance de ceux là entre eux.

Mathématiquement, cela se traduit ainsi :

mathVoronoi.png

Ce qui ce lit L'ensemble des points x appartenant à R² tel que pour tout point q appartenant à S la distance entre le point x et p soit inférieure ou égale à la distance entre le point x et q.

  • - Avec x, un point quelconque du plan R².
  • - Avec p, le point de référence ∈ S.
  • - Avec q, un point quelconque ∈ S.

Enfin, on peut ajouter qu'un point donné du plan qui est à égale distance de deux germes appartient à une frontière de Voronoï si ce point donné n'est pas à une distance moindre d'un autre germe.

Cela étant dit, en considérant les propriétés du diagramme de Voronoï, il est possible de construire une heightmap où la hauteur de chaque pixel serait déterminée selon sa distance avec le site le plus proche. Ce qu'on va faire pour commencer c'est se munir d'une graine suffisamment grande pour contenir tous nos sites. En effet, comme pour le bruit de perlin, ou le stamp noise, nous allons générer plusieurs octaves de notre bruit. Chacune des octaves aurait son propre ensemble de sites.

Tous nos sites sont stockés dans un tableau arbitrairement long de 4096 octects.

  	auto time = std::chrono::high_resolution_clock::now();
        auto count = time.time_since_epoch().count();

	std::default_random_engine generator;
	std::uniform_int_distribution<int> distribution(0,4294967295);
        generator.seed(std::default_random_engine::result_type(count));
		
	auto dice = std::bind ( distribution, generator );
		
	unsigned char * seed = new unsigned char[4096];
	int limit = 4096 / sizeof(unsigned int)
	for (int i = 0; i < limit; i++) {
		((unsigned int*) seed)[i] = (unsigned int) dice();
	}

On utilise les librairies standard chrono et random pour générer nos graines. À partir de là les choses restent relativement simples. On parcourt notre surface en x et en y

	surface[x + y * scale] += persistence * Voronoi(x, y, seeds);

et pour chaque pixel, on incrémente avec la valeur de la fonction Voronoi multipliée par la persistance.À titre de rappel, la persistance est en fait un coefficient définit par l'octave courante. Plus le grain du bruit est fin, plus la persistance est faible. Dans la pratique, et c'était déjà le cas dans le bruit de Perlin, ça permet de contrôler l'intensité maximale de chaque octave.

La fonction Voronoi est implémentée rigoureusement telle que décrit par la définition mathématique:

GLfloat VoronoiCell(int x, int y, unsigned char * seeds) {
	GLfloat tmp, dx, dy;
	
	GLfloat minDist = 16581375.0;
	for (int i = 0; i < seedsNumber; i++) {
		dx = (x - (((int*)seeds)[(seedOffset+i*2) & limit  ] & modScale));
		dy = (y - (((int*)seeds)[(seedOffset+i*2+1) & limit] & modScale));
		tmp = dx*dx+dy*dy;
		if (tmp < minDist) {
			minDist = tmp;
		}
	}

	return sqrt(minDist);
}

Pour un point donné on cherche le site le plus proche. Une fois ce site trouvé, on renvoie la distance associée.

Dans mon implémentation très simple seedOffset désigne la position de départ de l'ensemble des sites stockés à l’intérieur de seeds, le fameux tableau de 4096 float.

La variable limit est en fait une constante fixée a 4095. Pour éviter de faire un modulo sur une puissance de deux (4096), on peut utiliser l'opérateur "ET". On dirait pas comme ça, mais ça fait gagner approximativement 120 millisecondes!

Vous remarquerez que tout ça nous oblige à utiliser la fonction sqrt qui n'est pas connue pour être particulièrement rapide. En fait le programme est très lent. Il n'est pas particulièrement compliqué, mais il est très très lent. Quand il est implémenté côté cpu par exemple, pour une octave contenant 32 sites, la génération de la surface s'exécute en moyenne en 70 millisecondes sur un core i7. C'est pas terrible. C'est même très mauvais si on veut faire du temps réel. Néanmoins on peut substantiellement augmenter les performances en implémentant l'algorithme côté GPU. En effet il est très facile de paralléliser cet algorithme dans un compute shader:

#version 430

layout (local_size_x = 1, local_size_y = 1) in;
layout (rgba32f, binding = 0) uniform image2D voronoi;

uniform uint brushScale;
uniform vec4 voronoiSeeds[64];\n"

const uint area = brushScale*brushScale;

void main() {
	vec2 pixel = vec2(gl_GlobalInvocationID.xy);
	float minDistance = 16581375.0;
	vec2 d
	float tmp;
	for (int i=0; i < 64; i++) {
		d.x = pixel.x - voronoiSeeds[i].x;
		d.y = pixel.y - voronoiSeeds[i].y;
		tmp = d.x*d.x + d.y * d.y;

		if (tmp < minDistance) {
			minDistance = tmp;
		}
	}

	imageStore(voronoi, ivec2(gl_GlobalInvocationID.xy), vec4(vec3(sqrt(minDistance)),1.0));
}

Si vous ne connaissez pas OpenGL et en particulier le fonctionnement de ce qu'on appelle les shaders, il peut-être bon d'expliquer un peu comment ça marche. Un shader est un programme informatique s'exécutant sur la carte graphique de manière parallèle. Le langage de programmation utilisé pour écrire un shader OpenGL est GLSL, il est très proche du langage C.

Dans notre shader ci-dessus, nous avons une fonction main qui sera donc invoquée un certain nombre de fois en parallèle. Ce nombre est déterminé soit par l'utilisateur, soit par la pipeline de rendu. Ce qui est intéressant, c'est que la carte graphique peut manipuler plusieurs centaines d'invocations de shader en même temps. Inutile de vous dire que ça va très très vite et que c'est très pratique pour certains type d'algorithme. Dans notre cas, ça marche vachement bien parce que le programme est facilement parallélisable. On obtient de jolies performances, en effet le shader s'exécute en environ 15 à 20 millisecondes pour un diagramme de Voronoï de 32 sites pour une octave.

Dès qu'on augmente la profondeur, et donc le nombre d'octave, ça devient tout de suite plus lent, évidement, même sur GPU. En pratique, pour U N I V E R S E je ne pense pas utiliser directement ce type d'algorithme. Par contre il peut-être intéressant de combiner cette approche avec le Stamp Noise. C'est à dire que l'on générerait une fois une texture de Voronoï, pour une seule octave, qu'on réutiliserait comme on le faisait avec le cône de base utilisé dans l'implémentation original du Stamp noise.

Bon du coup, visuellement ça donne ça avec la fonction de Voronoi original.

voronoi32seeds1octave.png

voronoi32seeds1octaveBumped.png

Il est possible de sensiblement changer la fonction de Voronoi pour obtenir un résultat très différent, ainsi la fonction devient:

GLfloat VoronoiCrack(int x, int y, unsigned char * seeds) {
	GLfloat tmp, dx, dy;
	
	GLfloat minDist1 = 16581375.0;
	GLfloat minDist2 = 16581375.0;
	for (int i = 0; i < seedsNumber; i++) {
		dx = (x - (((int*)seeds)[(seedOffset+i*2) & limit  ] & modScale));
		dy = (y - (((int*)seeds)[(seedOffset+i*2+1) & limit] & modScale));
		tmp = dx*dx+dy*dy;
		if (tmp < minDist2) {
			if (tmp < minDist1) {
			  minDist2 = minDist1;
			  minDist1 = tmp;
			}
			else {
			  minDist2 = tmp;
			}
		}
	}

	return - sqrt(minDist2) + sqrt(minDist1);
}

Ici, ce qu'on fait, c'est qu'on cherche le second site le plus proche du point courant. On renvoie ensuite la différence. Voilà le résultat, toujours pour une octave et 32 sites:

voronoiCrack32seeds1octave.png

voronoiCrack32seeds1octaveBumped.png

Alors attention là les performances sont un peu réduites. Le temps d'exécution monte à 90 millisecondes sur un core i7.

Une autre variante consiste cette fois à ne plus faire la différence, mais à retourner le produit des deux distances, le temps d'exécution est toujours très élevé est reste approximativement le même que précédemment.

GLfloat VoronoiJelly(int x, int y, unsigned char * seeds) {
	GLfloat tmp, dx, dy;
	
	GLfloat minDist1 = 16581375.0;
	GLfloat minDist2 = 16581375.0;
	for (int i = 0; i < seedsNumber; i++) {
		dx = (x - (((int*)seeds)[(seedOffset+i*2) & limit  ] & modScale));
		dy = (y - (((int*)seeds)[(seedOffset+i*2+1) & limit] & modScale));
		tmp = dx*dx+dy*dy;
		if (tmp < minDist2) {
			if (tmp < minDist1) {
			  minDist2 = minDist1;
			  minDist1 = tmp;
			}
			else {
			  minDist2 = tmp;
			}
		}
	}

	return -sqrt(minDist2) * sqrt(minDist1);
}

voronoiJelly32seeds1octave.png

voronoiJelly32seeds1octaveBumped.png

Comme je le disais, en terme de performance, ce n'est pas folichon, mais visuellement c'est intéressant. Si on utilise des diagrammes de Voronoï avec le stamp noise, il n'y a pas besoin de générer plusieurs octaves sur ce type de bruit. La preuve de concept associée à cette article permet cependant de le faire. Puisqu'on est là, autant voir ce que ça donne!

La boucle de génération devient donc

	GLfloat persistence;

	for (int octave = 1; octave <= depth; octave++) {
	  	seedsNumber *= 4;
		persistence = (1.0 / (GLfloat) (1 << octave));
		for (int x = 0; x < scale; x++) {
			for (int y = 0; y < scale; y++) {
			  	#ifdef VORONOI_CRACK
					surface[x + y * scale] += persistence * VoronoiCrack(x, y, seeds);
				#else
			  		#ifdef VORONOI_JELLY
					surface[x + y * scale] += persistence * VoronoiJelly(x, y, seeds);
					#else
					surface[x + y * scale] += persistence * VoronoiCell(x, y, seeds);
					#endif
				#endif
				if(surface[x + y * scale] < min) {
					min = surface[x + y * scale];
				}
				if(surface[x + y * scale] > max) {
					max = surface[x + y * scale];
				}
			}
		}
		seedOffset += seedsNumber;
	}

Comme les temps de génération augmente vraiment VRAIMENT vite on se limite à quatre octaves. Également, pour un rendu plus intéressant me semble-t-il, j'augmente à chaque octave d'un facteur quatre le nombre de sites. Avec ça on arrive approximativement à 5 secondes sur un core i7 pour chacune des variations de la fonction de voronoï. Visuellement on obtient ceci:

voronoiCell32seeds4octave.png

voronoiCell32seeds4octaveBumped.png

voronoiCrack32seeds4octave.png

voronoiCrack32seeds4octaveBumped.png

voronoiJelly32seeds4octave.png

voronoiJelly32seeds4octaveBumped.png

C'est très esthétique, la combinaison de cette technique avec le Stamp Noise est très prometeuse, nous verrons ça dans un prochain article!

Pour essayer chez vous le programme, rendez-vous sur la page github du Poc.

Un grand merci à mes collègues de l'INRIA pour les conseils et les optimisations du code! Merci également à Laura pour sa précieuse relecture!

Génération procédurale de terrain: Part. III, Cube mapping

Nous allons aujourd’hui parler de cube mapping. Le procédé est absolument indispensable pour ce que l’on se propose de faire. Dans le dernier article nous avions étudié comment projeter un cube sur une sphère, hors, à ce moment, le cube était vierge. Nous voulons maintenant appliquer sur ce cube une heightmap. Cette heightmap se présentera sous la forme d’une texture particulière à la façon d’un patron de cube.

patroncube1.png

Notons par ailleurs qu’en ce qui concerne le cube mapping il existe plusieurs école. Une des technique utilisées et expliquées ici utilise notamment la carte graphique. Même si la carte graphique est très rapide, dans le cas du Stamp Noise cela oblige le programme à faire de très très nombreux allez-retours entre la mémoire vive de l’ordinateur et celle de la GPU. Par ailleurs l’algorithme est dépendant du GPU. La technique que nous allons voir a la prétention d’être raisonnablement optimisée et fonctionne sans GPU tout en offrant un temps d’exécution confortable qui permet au programme de s’exécuter en temps réel.

Enfin, avant de commencer, nous allons présenter les problématiques du cube mapping pour bien cerner la difficulté de l’entreprise. Notons également que l'article ne traitera que les "grosses" parties de la preuve de concept. Un article exhaustif en explications sur chaque détails du programme serait bien trop long. Nous survolerons ici la preuve de concept afin d’en avoir un aperçu raisonnable.

En premier lieu, on doit pouvoir subdiviser un nombre arbitraire de fois chacune des faces. Ces subdivisions sont locales. La raison est que nous voulons un niveau de détail dynamique ; à mesure que la caméra est proche de la surface de la planète on doit gagner en niveau de détail. Mais cela ne sert à rien de subdiviser l’ensemble de la cubemap, il ne faut subdiviser que les portions qui sont affichées à l’écran (sinon la mémoire va très vite être saturée). Le choix des portions est laissé à la discrétion d’un autre algorithme qui n’est pas encore implémenté et dont on ne parlera pas ici. Ci-dessous un exemple de subdivision.

cubemap2.png

Nous avons donc un ensemble de subdivisions, calculées à la volée, et dont on veut, en second lieu, que chacune des frontières correspondet à la frontière adjacente, autrement dit, on doit faire correspondre chacune des faces et des subdivisions qui composent ces faces entre elles de sorte à avoir un espace continu ; qui ne donne pas l’impression que les « pièces » auraient était mises ensemble n’importe comment. Typiquement on veut éviter ça

wrongCubemap.png

Sur cette dernière image on voit qu’une des subdivisions ne correspond pas à ses voisines, et qu’une face entière ne correspond également pas aux autres.

Bon. À partir de là on distingue quatre cas de figure que notre algorithme devra reconnaître et traiter. Pour visualiser ça on va prendre une cubemap dont chacune des faces est subdivisée une fois (ça fait quatre secteurs par face), cela nous permettra de mettre en évidence nos différentes situations. Rappelons que nous appliquons un tampon sur une matrice. Pour faire une surface continue il faut détecter quand ce tampon dépasse de la matrice courante pour faire en sorte que la partie du tampon qui dépasse soit « imprimée » de l’autre côté, sur la matrice adjacente correspondante. C’est là que tout se complique comme on va le voir :

patroncube3.png

Chaque petite « gommette » représente un tampon imprimé à un endroit de la cubemap. La couleur de la gommette renseigne le cas de figure auquel on s’expose.

En vert, c’est le cas trivial où le tampon est imprimé à l’intérieur du secteur courant. Il n’y a pas de traitement particulier à faire.

En bleu, c’est le cas un peu plus compliqué où le tampon dépasse soit sur l’axe x soit sur l’axe y, mais pas les deux à la fois.

En rouge, c’est le cas encore un peu plus compliqué où le tampon dépasse sur l’axe x ET y.

Enfin, en jaune, c’est le cas difficile mais plus rare où le tampon dépasse en x ET y ET sur un coin d’une face. C’est important de faire la distinction. Le dépassement sur le coin des faces implique un traitement particulier que nous verrons plus bas.

La première chose que l’on remarque c’est que dans les situations marquées en bleu , jaune et rouge il va falloir transformer les coordonnées du pixel courant quand celui-ci est en dehors de son secteur d’origine. Pour bien comprendre le phénomène on se donne encore un schéma qui illustre la problématique:

patroncube4.png

En effet si on se donne pour face de référence celle qui est marqué +z alors le système de coordonnées des autres faces, relativement à +z, change. Par exemple, lorsque l’on se déplace verticalement, vers le bas sur +x, on arrive à la face adjacente inférieur +y. Le déplacement ne se fait alors plus verticalement de haut en bas, mais horizontalement de droite à gauche.

Pour la petite histoire, la première version de la preuve de concept que l’on étudie aujourd’hui est monolithique. C’est à dire que tout le code tiens dans un seul fichier contenant deux ou trois grosses fonctions. Cette version là ne gérait que les cas relativement simples décrits par les gommettes vertes et bleues, et ne gérait pas la subdivision de surface. Le code devenait très compliqué et contrairement aux autres preuves de concept il ne pouvait décemment pas tenir dans un seul fichier source. Finalement, la preuve de concept actuelle est assez proche du produit final qui devra être intégré au code d’UNIVERSE. Pour jeter un œil à la première version du PoC, rendez vous [ici |https://github.com/DenisSalem/UNIVERSE/blob/master/PoC/cubeMapping/main.cpp.megamoth|en|megamoth]

Rappelons que pour le moment nous utilisions le C++ mais sans vraiment en tirer partie. Hors ici l’utilisation de la programmation orienté objet est tout à fait pertinente. L’idée centrale est que chaque face, appelé royaume, soit une classe gérant son espace. Chaque royaume est lié aux autres par références. Et pour pouvoir se déplacer dans un espace en dehors du royaume on définit des méthodes virtuelles implémentées pour chacune de ces classes. C’est le moment d’expliquer tout ça plus en détail via un diagramme UML qui va illustrer exactement ce que fait notre preuve de concept de cube mapping. Il est fortement suggéré d’être à l’aise avec les rudiments d’UML et de la programmation orientée objet pour comprendre ce qui va suivre.

cubemappingUML.png

Comme nous le disions, on se donne une classe abstraite Realm dont on implémente six fois (une fois pour chaque faces) les fonctions de correspondances de coordonnées. Les systèmes de coordonnées implémentés sont relatifs à la face courante et décrivent comment se déplacer sur les faces adjacentes. Ces fonctions sont notées en gras et en italique.

Dans la classe abstraite Realm, les quatre premières méthodes abstraites décrivent des déplacement sur l’axe vertical ou horizontal, mais pas les deux axes à la fois. Les huit fonctions restantes ne gèrent que les dépassement simultanés sur l’axe horizontal et vertical. Ces dernières fonctions sont utilisés quand le dépassement se fait sur le coin d’un secteur. Sans pour autant dépasser sur le coin d’une face. Cette situation est illustrée par les gommettes rouges sur la figure plus haut.

Les quatres premières fonctions

  • GetCoordsToNeighbourTop
  • GetCoordsToNeighbourBottom
  • GetCoordsToNeighbourLeft
  • GetCoordsToNeighbourRight

correspondent au cas d’usage illustré par les gommettes bleues.

La classe abstraite Realm comporte finalement quatre grosses méthodes qui correspondent à chacune des couleurs de gommettes vues plus haut :

  • StampWithinChunk
  • StampBeyondBorder
  • StampBeyondCorner
  • StampBeyondCornerBeyondRealm

Dans la fonction principale de bas niveau Noise (la fonction est privée et est appelée par son homonyme public) on analyse le décalage aléatoire du tampon pour décider quelle fonction de tamponnage utiliser.

  if ( this->IsChunkACorner(chunkCoordX,chunkCoordY,chunkScale-1) && stampCrossCornerBeyondRealm) {
    this->StampBeyondCornerBeyondRealm(sectorScale, halfScale, offsetX, offsetY, stampCrossCornerBeyondRealm);
  }
  else {
    chunkLimit = chunkScale - 1;
    for (int y=0; y<sectorScale; y++) {
      stampX = 0;
      for (int x=0; x<sectorScale; x++) {
        stampIndex = stampX + sectorScale * stampY;
	if ( stamp[ stampIndex ] != 0) {
          if (x+offsetX >= 0 && x+offsetX < this->scale && y+offsetY >= 0 && y+offsetY < this->scale) {
            this->StampWithinChunk(x+offsetX, y+offsetY, stampIndex);
          }
          else {
            StampBeyondCorner(x+offsetX, y+offsetY, chunkCoordX, chunkCoordY, chunkLimit, stampIndex);
            StampBeyondBorder(x+offsetX, y+offsetY, chunkCoordX, chunkCoordY, stampIndex);
          }
        }
        stampX += this->step;
      }
      stampY += this->step * this->step;
    }
  }

En effet, le premier test consiste à vérifier le cas particulier (et rare) du tampon qui dépasse sur un coin d’une face. Si c’est le cas on appelle StampBeyondCornerBeyondRealm. Sinon c’est que l’on se trouve dans une situation relativement simple à gérer : Soit le tampon est contenu exactement à l’intérieur du secteur courant, soit il en dépasse. S’il dépasse on appelle successivement StampBeyondCorner et StampBeyondBorder. À l’intérieur de celle-ci s’effectuent les tests qui doivent décider si oui ou non le tampon est appliqué. À ce niveau de code, le tampon est écrit nécessairement par l’une ou l’autre fonction.

Dans la fonction StampBeyondBorder on fait la distinction entre le dépassement sur un bord à l’intérieur d’une face ou vers l’extérieur d’une face. Par exemple :

inline void Realm::StampBeyondBorder(int x, int y, int chunkCoordX, int chunkCoordY, int stampIndex) {
  if(this->horizontalNeighbourChunk != 0 && x < 0 && y >= 0 && y < this->scale) {
    if (chunkCoordX == 0) {
      this->chunkIndex = this->GetCoordsToNeighbourLeft(x, y, this->scale) ;
    }
    else {
      this->chunkIndex = y * this->scale + this->scale + x;
    }
    horizontalNeighbourChunk[chunkIndex] += this->sign * this->stamp[ stampIndex ] / this->step;
    this->UpdateMinMax(chunkIndex, this->horizontalNeighbourChunk);
  }

De façon identique, dans StampBeyondCorner on regarde si le tampon dépasse sur le bord d’une face, sur le coin d’un secteur ou si le dépassement se fait sur le coin d’un secteur à l’intérieur de la face courante

  if (this->diagonalNeighbourChunk != 0) {
    if(x < 0 &&  y < 0) {
      if (chunkCoordX == 0) {
        this->chunkIndex = this->GetCoordsToNeighbourLeftTopLeft(x, y, this->scale) ;
      }
      else if (chunkCoordY == 0) {
        this->chunkIndex = this->GetCoordsToNeighbourTopTopLeft(x, y, this->scale) ;
      }
      else {
        this->chunkIndex = this->scale + x + this->scale * (this->scale + y);
      }
    }

J’aimerais rappeler que l’on ne parle volontairement pas des fonctions intermédiaires nécessaires au bon déroulement du programme. Je pense par exemple aux fonctions qui préparent les pointeurs désignant les espaces mémoire à remplir. Pour une compréhension plus complète de la preuve de concept vous êtes invité à vous plonger dans le code source, disponible sur Github.

Ceci étant dit, on arrive à la partie importante de la preuve de concept. Jusqu’à présent nous pouvions travailler sans trop faire de mathématiques, les opérations effectués étaient relativement simple et ne nécessitaient pas de trigonométrie. Nous allons parler de la façon dont on peut imprimer le tampon sur trois faces adjacentes, à l’endroit où les coins de celles là se confondent.

Le problème est le suivant ; jusqu’à présent, nous savions faire ça

cubemap5.png

C’est à dire imprimer le tampon sur des surfaces contiguës que l’on peut ramener sur le même plan sans distorsion. Ça se complique dans cette situation :

cubemap6.png

Dans cette situation le secteur de la face A et le secteur de la face C ne sont pas contiguës. Il n’y a rien qui lie les bords de ceux là, c’est le vide. Hors le tampon (la partie en rouge) chevauche cet espace vide. On ne peut pas simplement supprimer cette partie rouge sinon il y aurait un évident problème de correspondance au moment de la jointure (quand on transforme le patron en cube). Il faut donc ruser. Je propose d’utiliser un espace de transition que l’on peut représenter ainsi :

transitionSpace.png

Comme on le voit, par distorsion, on a joint les trois faces A,B et C. Notre espace de transition c’est ça : Entre chaque face, en prenant comme point de référence là où les coins de confondent, on a 120°. Avec trois faces, on a donc bien trois fois 120° soit 360° ; un tour complet. Du coup la suite des hostilités va consister à déterminer, en fonction du décalage aléatoire la distance entre le centre du tampon à l’endroit ou il devrait être imprimé normalement, sans l’espace de transition, et le coin sur lequel le tampon dépasse. On récupère également l’angle que fait le centre du tampon avec le coin qui nous intéresse.

cubemap7.png

L’angle que fait le centre du tampon peut systématiquement varier entre 0 et 90°. L’objectif est de reporter et mettre à l’échelle cet angle dans l’espace de transition à l’aide d’un produit en croix. Cet à dire que l’on va positionner le tampon sur la face correspondante, déformée, de l’espace de transition, dans laquelle sa position angulaire variera non plus entre 0 et 90° mais entre 0 et 120°. La distance du centre du tampon avec le point où les faces se confondent reste la même. Dans la pratique on a quelque chose comme ça :

    // Préparation des coordonnées de référence.
    transitionSpace = new float [transitionSpaceScale * transitionSpaceScale]();
    StampCenterCoordX = halfScale - ABS(offsetX % sectorScale);
    StampCenterCoordY = halfScale - ABS(offsetY % sectorScale);
    distanceFromStampCenterToCorner = sqrtl(pow(StampCenterCoordX,2) + pow(StampCenterCoordY,2))*this->step;
    angleStampCenterCorner = this->SetAngle(stampCrossCornerBeyondRealm, StampCenterCoordY, StampCenterCoordX);

    // Préparation des coordonnées de transition
    if ( stampCrossCornerBeyondRealm == CORNER_TOP_LEFT) {
      newAngle = - PI2_12 + (( angleStampCenterCorner) * PI2_3) / PI_2 ;
    }
    else if(stampCrossCornerBeyondRealm == CORNER_TOP_RIGHT) {
      newAngle = -PI2_12 + (( angleStampCenterCorner + PI_2) * PI2_3) / PI_2 ;
    }
    else if ( stampCrossCornerBeyondRealm == CORNER_BOTTOM_LEFT || stampCrossCornerBeyondRealm == CORNER_BOTTOM_RIGHT) {
      newAngle = - PI2_12 + (( angleStampCenterCorner) * PI2_3) / PI_2 ;
    }

    newStampOffsetX = (int) halfTransitionSpaceScale - (this->scale >> 1) + cos(newAngle) * distanceFromStampCenterToCorner ;
    newStampOffsetY = (int) halfTransitionSpaceScale - (this->scale >> 1) - sin(newAngle) * distanceFromStampCenterToCorner ;

On remarque tout de même que la détermination du nouvel angle, sur l’espace de transition est un peu plus compliqué. En effet, puisqu’on travaille sur le cercle trigonométrique, pour une position donnée , l’angle correspondant n’est pas nécessaire compris entre 0 et 90°. Il faut donc compenser. Par exemple dans le cas où le dépassement se fait sur le coin supérieur droit, cela veut dire que l’angle correspondant sur le cercle trigonométrique sera compris entre – 180° et -90°. On ajoute donc 90° (PI/2) pour être dans une plage d’angle qui fait sens.

Mais ce n’est pas tout. Une fois le produit en croix terminé ;

(( angleStampCenterCorner + PI_2) * PI2_3) / PI_2

On a bien une variation angulaire comprise entre 0 et 120°. Il faut maintenant rajouter un nouveau décalage. En effet, l’angle de départ varie selon la face et le coin sur lequel on dépasse. Dans le cas du dépassement sur le coin supérieur droit on à une variation finale négative entre 0 et -120°. On se donne comme angle de départ pour la face B -30° soit -2Pi/12

transitionSpace2.png

d’où

newAngle = -PI2_12 + (( angleStampCenterCorner + PI_2) * PI2_3) / PI_2 ;

Et voilà, avec ça on est capables d’imprimer le tampon sur l’espace de transition qui, à coup sûr, dépassera sur les autres faces adjacentes.

Du coup, on arrive à l'étape finale de notre cas particulier. Il s'agit maintenant de rapatrier les données de l'espace de transition vers la cubemap. Pour ça nous allons travailler successivement sur trois face. À chaque fois, pour un pixel donné on déterminera la position angulaire et la distance de ce pixel avec le coin ciblé. Ce sera ensuite la même mécanique ; mise à l’échelle de l’angle et détermination des coordonnées sur l’espace de transition. Finalement on prélève le pixel de l’espace de transition à l’aide de ces nouvelles coordonnées pour l’appliquer à la surface de destination, sur la cubemap.

Quelque soit la face de destination il y a une optimisation non négligeable que l’on peut s’offrir. En effet, on a pas besoin de parcourir toute la face de destination courante mais seulement une partie comme illustré ci-dessous :

circleLimit.png

En effet le tampon, à taille maximale, ne dépassera jamais de la zone circulaire décrite par le cercle gris et de rayon égal à la taille du secteur courant. Du coup, dans le code, nous aurons ceci dans le cas d’un dépassement sur le coin supérieur droit :

      for(int x = 0; x < sectorScale; x++) {
        for(int y = 0; y < sectorScale; y++) {
          currentPixelDistance = sqrtl( pow( sectorScale-x,2) + pow(-y-1,2) );
          // Pour économiser un peu de temps CPU on évite des calculs inutiles en s'assurant
          // que le pixel courant est dans une zone d'impression qui fait sens.
          // On réitérera ce test plus bas.
          if(currentPixelDistance < sectorScale) {
		...
          }
	 …
        }
      }

Dans la version actuelle du code une autre optimisation possible mais que je n’ai pas encore implémentée est de créer une matrice contenant toutes les valeurs utiles des racines carrées, ce qui nous octroiera un gain de temps énorme. En effet, la fonction sqrtl est très gourmande en temps CPU...

À présent, si l’on est dans une zone d’impression valide, on peut calculer l’angle que fait le pixel courant avec le coin qui nous intéresse, ici toujours le coin supérieur droit.

Notons que pour éviter les artefacts, on ajoute 1 ou -1 à la coordonnée courante, dans certains cas, pour éviter d’avoir une valeur nulle, ce qui produirait des angles doublons à l’origine de petits artéfacts visuels.

            currentPixelAngle = atan2((double) -y-1, (double) -sectorScale+x);

Après quoi, on met à l’échelle l’angle pour l’espace de transitionSpace

            newAngle = - PI2_12 + (( currentPixelAngle + PI_2) * PI2_3) / PI_2 ;

Duquel on va déduire les nouvelles coordonnées sur cet espace de transition

            newX = (int) (cos(newAngle) * scaledCurrentPixelDistance + halfTransitionSpaceScale);
            newY = (int) (-sin(newAngle) * scaledCurrentPixelDistance + halfTransitionSpaceScale);

Enfin, il n’y a plus qu’a prélever la valeur du pixel sur la zone de transition

               chunkIndex = this->scale - sectorScale + x + this->scale * y;
            this->localChunk[ chunkIndex ] += this->sign * transitionSpace[newX + newY * transitionSpaceScale] / this->step;
            this->UpdateMinMax(chunkIndex, this->localChunk);

Finalement pour la face courante du coin supérieur droit on a

      for(int x = 0; x < sectorScale; x++) {
        for(int y = 0; y < sectorScale; y++) {
          // ROYAUME COURANT
          currentPixelDistance = sqrtl( pow( sectorScale-x,2) + pow(-y-1,2) );
          // Pour économiser un peu de temps CPU on évite des calculs inutiles en s'assurant
          // que le pixel courant est dans une zone d'impression qui fait sens.
          // On réitérera ce test plus bas.
          if(currentPixelDistance < sectorScale) {
            scaledCurrentPixelDistance = currentPixelDistance * this->step;
            // À surveiller: les coordonnées passées à atan2 ne doivent jamais être nulles,
            // sinon cela produit des artefacts. D'où le -y-0.0001.
            currentPixelAngle = atan2((double) -y-1, (double) -sectorScale+x);
            newAngle = - PI2_12 + (( currentPixelAngle + PI_2) * PI2_3) / PI_2 ;
            newX = (int) (cos(newAngle) * scaledCurrentPixelDistance + halfTransitionSpaceScale);
            newY = (int) (-sin(newAngle) * scaledCurrentPixelDistance + halfTransitionSpaceScale);
            chunkIndex = this->scale - sectorScale + x + this->scale * y;
            this->localChunk[ chunkIndex ] += this->sign * transitionSpace[newX + newY * transitionSpaceScale] / this->step;
            this->UpdateMinMax(chunkIndex, this->localChunk);
          }
	…
        }
      }

En mode debug, pour un seul niveau de récursion on obtient visuellement ça

transitionSpaceFinal.png

Comme on le voit, le tampon imprimé en haut à droite est bien raccord! Pour terminer, on obtient bien une superbe cubemap, prête à l'emploi!

cubeMapFinal.png

En exécutant le code en condition normale, c'est à dire sans écrire dans un fichier, le code s’exécute relativement rapidement

anonyme@anonymoushost /home/http/dev/UNIVERSE/PoC/cubeMapping $ time ./cubeMapper 

real	0m0.428s
user	0m0.391s
sys	0m0.036s

Notons que la preuve de concept génère en fait six fois quatre secteurs de 256 pixel² (chaque faces est subdivisée en quatre). Ce qui fait environs 17ms par secteur. C'est un temps de génération tout à fait raisonnable mais qui devrait pouvoir être amélioré dans de futures versions, en effet dans la preuve de concept du Stamp Noise le temps d’exécution pour une surface de 256 pixel² était moitié moins long,

Pour découvrir la preuve de concept intégralement rendez-vous ici

À bientôt pour un prochain article !

Sphère procédurale

Comme nous l'avons vu, nous savons générer des surfaces procédurales. Ces surfaces sont raisonnablement convaincantes. Nous pourrions encore les améliorer, mais il me semble que nous pouvons pour le moment essayer de les utiliser en l'état pour en faire quelque chose, en 3D par exemple !

J'ouvre une parenthèse pour vous signaler que maintenant que nous commençons à faire de la 3D, autant que possible je m'efforcerai de ne pas rentrer dans les détails techniques d'OpenGL pour me concentrer sur l'aspect théorique de la création et des algorithmes. Vous êtes cependant fortement encouragé à apprendre à manipuler cette API si ce n'est pas déjà le cas.

Les terrains procéduraux que nous avons déjà faits jusqu'à présent pourraient être mis bout à bout sur une surface plane infinie. On aurait un paysage, et on pourrait le peupler. Mais ce ne serait pas très intéressant pour notre projet. Ce que nous voudrions plutôt, c'est par exemple créer une planète procédurale, ce qui est très différent techniquement.

Dans le tout premier article à propos de génération procédurale où l'on parlait du bruit de Perlin j'avais rapidement expliqué ce qu'était le displacement mapping. À titre de rappel, c'est le procédé par lequel on fait s'élever un vertex dans une direction donné en fonction de l'intensité du pixel de la heightmap qui lui est associé. Sur une surface plane, c'est très facile. On surélève le vertex selon l'axe z, par exemple.

displacement.png

Dans le cas d'une sphère c'est un petit peu plus compliqué parce qu'on ne travaille pas dans le même système de coordonnées. Tout comme dans un repère cartésien un point a trois coordonnées qui cette fois ne forment plus le triplet (x,y,z) mais (θ,Φ,r) dont chaque symbole Théta majuscule, Phi majusctule et r sont respectivement appelés latitude, longitude et altitude.

La première chose que l'on comprend intuitivement, c'est que l'on peut faire correspondre les trois coordonnées cartésiennes avec leurs équivalents sphériques, en particulier, l'axe z deviens l'altitude r. Ce qui veut dire qu'une fois que nos surfaces procédurales serons projetées sur la sphère, c'est l'altitude r qu'on modifiera en fonction de l'intensité du pixel local.

Mais maintenant se pose un plus gros problème. Un problème majeur même, millénaire pourrait on dire, parce que c'est une affaire de cartographes. Il s'agit de savoir comment projeter une surface plane contenant des informations topologiques sur une sphère. Ce problème n'est pas trivial du tout et il existe de très nombreuses façons de faire selon le contexte dans lequel on travaille. Il va donc falloir poser au moins trois contraintes.

  1. Faible cout en terme de calculs au moment de la génération de la sphère et de la projection de la surface sur celle-ci.
  2. Faible distorsions.
  3. La sphère et la surface projetée doivent être simple à manipuler à postériori.

Je ne vais pas rentrer dans le détail, mais avec ça on peut déjà exclure pas mal d'approches. On va tout de même prendre le temps d'expliquer pourquoi on ne va pas utiliser la plus intuitive d'entre elles: La projection équirectangulaire.

Commençons par voir comment ça marche.

La projection équirectangulaire consiste à plaquer sur une sphère un rectangle de dimensions n en hauteur et 2n en largeur. Pour ce faire on fait correspondre comme on l'a dit les coordonnées x et y aux coordonnées sphériques (θ,Φ). Attention cependant, la latitude θ varie entre -90° et 90° alors que la longitude Φ varie entre -180° et 180° (ou 0 et 360°, selon la convention). Autrement dit il faudra faire varier linéairement les coordonnées sphériques en fonction des coordonnées cartésiennes à l'aide d'un produit en croix.

Voilà pour l'essentiel. Si on procède ainsi cependant la projection produira de très fortes distorsions topologiques et ça ne sera pas joli du tout. Ce qui signifie qu'il faut traiter en amont la heightmap rectangulaire avant de la plaquer sur la sphère. Ce traitement va consister à appliquer des distorsions sur la heightmap qui seront compensées une fois plaquée sur la sphère. Pour vous aider à visualiser le phénomène il suffit d'observer une mappemonde construite sur ce principe.

Sphère

On voit bien que plus on se rapproche des pôles, c'est à dire aux extremums de la latitude, plus les continents semblent écrasés.

On à donc deux calculs à faire, couteux qui plus est. Le premier calcul est celui de la distorsion de la heightmap, le second calcul est celui du placement des vertex de la sphère dont on modifie l'altitude en fonction de la heightmap que l'on vient de transformer.

Sphère

Bon. Cette solution ne convient pas du tout. Elle ne convient pas déjà parce qu'elle est très couteuse en temps de calcul. Par ailleurs, elle à le désavantage majeur de ne pas être commode à manipuler. En effet pour notre planète, si nous voulons la peupler dynamiquement, on devra manipuler en temps réel les informations topologiques contenues dans la heightmap… Hors celle-ci aura été transformée. Ça ne va donc pas l'faire. On pourrait stocker une copie non déformée dont on se servirait comme référence, mais ça impliquerait à chaque fois que l'on pose des éléments sur la heightmap (des villes, des routes, des forêts) de leur appliquer ensuite la distorsion qui convient en fonction de la latitude. Enfin pour le niveau de détail dynamique -fonction de la distance camera/surface- ça va vite devenir très compliqué pour les mêmes raisons.

La projection équirectangulaire n'est donc pas terrible pour ce qu'on se propose de faire.

Non, ce qu'on va faire c'est… Projeter un cube sur une sphère ! La génération du cube n'étant pas compliqué en soi il est très facile ensuite de transformer l'objet en sphère. La distorsion induite par la transformation est minime et chaque face du cube est facilement manipulable à posteriori. Enfin il est possible de plaquer six heightmaps sur chacune des faces du cubes sans traitement préalable particulier.

Cela étant dit on va pouvoir attaquer la génération du cube. Pour ce faire on va déjà voir comment on s'y prend pour générer une face avec un nombre arbitraire de vertex. Nous allons voir que la question est un peu plus compliquée qu'il n'y paraît. Parce qu'il y a plusieurs façon d'organiser les vertex.

La première chose à savoir, c'est que la carte graphique, pour afficher des formes complexes, travaille en fait avec des primitives. C'est à dire des points, des segments, ou des triangles qui seront ensuite assemblés pour former un objet complexe. La plupart du temps on travaille avec des triangles parce qu'ils permettent de créer des surfaces sur lesquelles on peut afficher des textures et des effets.

Du coup, un carré, c'est au moins deux triangles.

squareByTwoTriangles.png

Mais nous on veut avoir avoir un carré, certes, mais composé de nombreux vertex, et donc de nombreux triangles pour pouvoir y appliquer la heightmap. Ce qui nous amène à la problématique d'assemblage des primitives. Dans le cas des triangles, OpenGL propose plusieurs façon de s'organiser. On en retiendra deux.

primitives.png

Dans le premier cas, très simple, il faut effectivement trois vertex par triangle. Ce qui veut dire que pour chaque cellule de notre surface carrée il faut compter six vertex. C'est pas génial. C'est pas génial parce qu'avec le mode GL_TRIANGLE_STRIP on peut drastiquement diminuer le nombre de vertex à envoyer à la carte graphique. Dans ce mode chaque triangle est dessiné en fonction du vertex courant, et des deux derniers vertex placés. Cela fait économiser énormément de vertex. Par exemple sur une ligne complète de carrés successifs. Disons 10 carrés on passe de soixante vertex (GL_TRIANGLES) à vingt vertex (GL_TRIANGLE_STRIP). Pas mal ! C'est donc très pratique, mais l'inconvénient, on s'en doute, c'est que la structure des vertex doit être repensée pour des formes complexes. Parfois, il n'est tout simplement pas possible de représenter une forme géométrique sans triangles dégénérés. Et même l'utilisation de ces triangles dégénérés peut être laborieuse dans certains cas. Heureusement pour générer une grille, c'est relativement simple. C'est ce que nous allons voir maintenant.

Une grille carrée va être composée de lignes de cellules carrées. Il y aura autant de lignes que de colonnes. Dans le cas de la première ligne, quand on arrive au bout on se rend compte que l'on a un problème.

degeneratedTriangles1.png

En effet le 7éme vertex ne permet pas de faire un triangle puisqu'il est parfaitement aligné avec les deux précédents. C'est là qu'interviennent les triangles dégénérés.

Un triangle dégénéré est un triangle aplati, ou même réduit à un point, quand chacun des trois points ont les mêmes coordonnées. Dans notre cas nous aurons besoin de deux triangles dégénérés. La raison est que le premier triangle dégénéré va inverser l'orientation du triangle courant, et de tout ceux qui suivront après. On rajoute donc un second triangle dégénéré (donc un vertex en plus) pour restaurer l'orientation des triangles. On ne va pas rentrer dans les détails mais l'orientation des triangles peut-être problématique plus tard.

degeneratedTriangles2.png

Ainsi le neuvième vertex et le huitième vertex forment le segment qui n'attend plus que le dixième vertex, placé sur le quatrième, clôturant ainsi le premier triangle de la seconde ligne. Il n'y a plus qu'a répéter l'opération pour chaque ligne.

Ces vertex doivent être sauvegardés quelque part, évidemment, et comme on veut éventuellement contrôler la résolution de la planète, on doit déterminer une formule qui indique le nombre de vertex à stocker – en vue d'allouer dynamiquement la mémoire – en fonction d'un paramètre n qui lui détermine la résolution.

La formule pour déterminer le nombre de vertex dans le cas d'une grille dont la résolution est une puissance de deux est donc la suivante :

2^2n * 2 -  2^n - 2

Où ici 2^n est la dimension de la grille.

Cela se démontre assez facilement si on se représente schématiquement la grille et que l'on compte le nombre de vertex en tenant compte des triangles dégénérés.

theGridADigitalFrontier.png

À présent on peut jeter un œil à l'algorithme qui permet de « dessiner » la grille tel que je l'ai implémenté et commenté.

Alors attention, on pourra remarquer qu'en fait je ne place pas exactement les vertex. Je sélectionne des indices qui pointeront sur une grille contenant n² vertex. Ce système est un mécanisme proposé par OpenGL qui permet encore une fois d'économiser de la mémoire et de la bande passante.

L'idée est la suivante : Un vertex est généralement un triplet de nombres à virgule flottante (float). Un vertex, donc, consomme 4 * 3 octets. Si on transfère directement les vertex à la GPU on consommera un nombre d'octets déterminé par la relation suivante

S(n) = (2^2n * 2 -  2^n – 2) * 4 * 3

Avec S, fonction de n, le nombre d'octets nécessaire.

Dans notre cas on crée une grille de n² vertex et on pointe sur chaque vertex à l'aide d'un entier court (short int) codé sur deux octets. L'espace mémoire s(n) consommé par les vertex et les indices devient alors

s(n) = (2^2n * 2 -  2^n – 2) * 2 + (2^n) * 4 * 3

pour un n relativement grand on gagne énormément de place. Pour s'en convaincre je vous propose un petit graphique avec en rouge S(n) et en bleu s(n). La différence est considérable.

memoryConsumption.png

Cela étant dit on peut enfin passer au code :

  // Permet de déterminer la direction du placement des vertex.
  bool reverse = false;

  // Coordonnées initiales 
  int x = 0;
  int y = 0;

  // Indice
  int i = 0;

  // On travaille dans une boucle while, ça me semblait plus commode:
  // On peut contrôler conditionnellement l'incrémentation de i.
  while (true) {

    // Les vertex sont placés par deux, verticalement.
    this->index[ i ]     = this->cubeScale * y     + x; 
    this->index[ i + 1]  = this->cubeScale * (y+1) + x;

    i+=2;

    // Quand on arrive au bout d'une ligne on change de direction
    if (x == this->cubeScale -1 || (x == 0 && i > 2)) {
      // Si en plus on arrive au bout de la dernière ligne, on arrête tout.
      if(y == this->cubeScale -2) {
        break;
      }

      // On change de direction
      reverse = !reverse;
      y++;

      // On Double le vertex courant
      this->index[ i ]     = this->cubeScale * y + x; 
      this->index[ i + 1]  = this->cubeScale * y + x;

      // On place le vertex qui formera le premier segment de la ligne
      this->index[ i + 2]  = this->cubeScale * (y+1)+x;
      i+=3;
    }

    // On se déplace le long de la ligne selon la direction
    if (reverse) {
      x--;
    }
    else {
      x++;
    }
  }

Rajoutons que l'on est pas obligé d'utiliser un tableau d'entiers courts. Nous pourrions utiliser des entiers normaux. La raison est que je me suis fixé une résolution maximale pour la grille que je juge suffisante, soit 7² vertex². On est donc limité par la taille de l'index, mais on gagne en rapidité (deux fois moins de données à transférer). La résolution relativement faible de la grille de vertex pourra être compensé avec d'autres astuces que nous verrons dans un autre article.

Maintenant que nous savons créer une grille (une surface carré), nous savons de fait créer un cube. Il ne reste plus qu'à transformer celui-ci en sphère. Alors attention cependant, dans mon implémentation chaque face du cube est rendu à l'écran séparément. Le cube n'est donc pas un objet indivisible, mais plutôt un objet dont chaque face est indépendante.

cube.png

Maintenant, c'est le moment de faire un peu de géométrie

Pour un point donné sur le cube on sait que la distance entre ce point et le centre du cube est donné par la relation

r = √( x² + y² + z²)

Tout à fait de la même façon qu'un carré inscrit dans un cercle trigonométrique.

trigo.png

En effet à l'intersection du carré sur le cercle, le rayon vaut un sur le cercle unité :

r = √(x² + y²)

Le rayon va varier entre 1 et √(1/2), soit 0,707106 quand on évalue le point sur le carré et non sur le cercle.

Pour projeter le point sur le carré (ou le cube dans notre cas)

On multiplie chaque coordonnée du point par l'inverse de son rayon. Par exemple dans le cas du cube nous aurons :

v = 1.0 / √( x² + y² + z²)

{
  X = x * v,
  Y = y * v,
  Z = z * v
}

Avec X,Y,Z les coordonnées finales. Et voilà ! Avec ça on a notre sphère !

sphere.png

On peut tout de même se fendre d'une petite optimisation en calculant une seule fois tous les coefficients pour une seule face. Ceux là étant calculés et stockés, il n'y a plus qu'a y accéder pour chaque face du cube.

  float * radiusPerVertex = new float[this->vertexSize];
  for (int y=0; y<this->cubeScale; y++) {
    for (int x=0; x<this->cubeScale; x++) {
      v1 = -0.5 + x * step;
      v2 =  0.5 - y * step;
      radiusPerVertex[this->cubeScale*y + x] = 1.0/sqrt( pow(v1, 2) +  pow(v2, 2) +  pow(0.5, 2) );
    }
  }
  
  // create vertices
  for (int y=0; y<this->cubeScale; y++) {
    for (int x=0; x<this->cubeScale; x++) {
      v1 = -0.5 + x * step;
      v2 =  0.5 - y * step;

      // Pour éviter de déréférencer 500 mille fois on stocke le rayon courant une bonne fois pour toute.
      v3 = radiusPerVertex[this->cubeScale*y + x];  

      this->vertex[0][this->cubeScale*y + x].x = v1 * v3;
      this->vertex[0][this->cubeScale*y + x].y = v2 * v3;
      this->vertex[0][this->cubeScale*y + x].z = 0.5 * v3;

      this->vertex[1][this->cubeScale*y + x].x = v1 * v3;
      this->vertex[1][this->cubeScale*y + x].y = v2 * v3;
      this->vertex[1][this->cubeScale*y + x].z = -0.5 * v3;

      this->vertex[2][this->cubeScale*y + x].x = v1 * v3;
      this->vertex[2][this->cubeScale*y + x].y = -0.5 * v3;
      this->vertex[2][this->cubeScale*y + x].z = v2 * v3;

      this->vertex[3][this->cubeScale*y + x].x = v1 * v3;
      this->vertex[3][this->cubeScale*y + x].y = 0.5 * v3;
      this->vertex[3][this->cubeScale*y + x].z = v2 * v3;
      
      this->vertex[4][this->cubeScale*y + x].x = 0.5 * v3;
      this->vertex[4][this->cubeScale*y + x].y = v1 * v3;
      this->vertex[4][this->cubeScale*y + x].z = v2 * v3;

      this->vertex[5][this->cubeScale*y + x].x = -0.5 * v3;
      this->vertex[5][this->cubeScale*y + x].y = v1 * v3;
      this->vertex[5][this->cubeScale*y + x].z = v2 * v3;
    }	
  }

Pour découvrir le PoC entier rendez vous ici : Les parties décrites dans cet article se trouvent dans le répertoire sphere/vertexFactory.cpp

On est bons pour la sphère, à la prochaine pour un nouvel article !

Merci à Benjamin Loubet pour l'aide et la relecture orthographique

Les enjeux de la réalité virtuelle : Le rêve assisté par ordinateur

dreamAboutCity.png

Très jeune, j'ai commencé à jouer aux jeux vidéos. Je ne pense pas être un gamer hardcore mais j’apprécie de jouer, de temps à autres, à quelques titres. Quand j'ai commencé à jouer à mes premiers jeux mes parents nous limitaient moi et mon petit frère à deux heures de jeu les mercredis, samedis et dimanches après midi. Une heure pour l'un et l'autre. Une heure ou je jouais, très concentré, et une autre heure où je regardais non moins intensivement mon frère jouer. Je pense que cette configuration a largement contribué à me rendre capable de faire des rêves lucides, encore aujourd'hui bien que je ne joue plus tant que ça. Le fait que ces séances aient un caractère ponctuel a participé à nourrir notre imaginaire à tous les deux puisque ces séances nous occupaient encore l'esprit durant la semaine ; on se demandait quelle serait la suite de nos aventures que l'on dessinait beaucoup après coup; on en parlait quotidiennement. Le souvenir du jeux vidéo persistait en nous en permanence pas tant comme un refuge à une réalité triste à pleurer que comme une authentique expérience vivante.

Rétrospectivement je me dis que cela a habitué mon cerveau au principe de réalité alterné. C'est à dire le fait que la conscience puisse être projetée en dehors de soi, dans un avatar ou un personnage de jeu vidéo. Et même si une interaction physique est nécessaire pour le moment -via le joystick ou le clavier-, l'esprit du joueur peut-être simultanément dans le jeu et en dehors. Cette attitude rend donc avec le temps l'esprit apte à distinguer spontanément dans quel réalité il se trouve. Ça devient très intéressant lorsque l'on se met à rêver. Si l'on prend conscience que l'on rêve on est libre. Une liberté dont on ne peut pas jouir dans la réalité physique à moins de maintenir cette sorte d'état de grâce. Cette liberté est d'autant plus intense à mesure que l'on accumule les expériences, même virtuelles.

En outre, le rêve, quand il est lucide, devient une authentique expérience vécue. Ceux qui ont l'habitude de pratiquer le rêve lucide savent de quoi je parle. Des rencontres fabuleuses, des lieux incroyables, des situations épiques, tendres, étranges. Pour la plupart d'entre nous le rêve est vécu passivement, comme en état d'ébriété, dans une sorte de torpeur confuse et sourde, cet état rend donc l'expérience onirique inconsistante. C'est un peu comme essayer de raconter une folle journée où vous seriez complètement déchiré aux drogues dures et aux boissons fortement alcoolisées. Vous n'en tirez rien de bien intéressant ou cohérent, c'est éventuellement rigolo dans l'instant, mais sans plus. Dans un rêve lucide, lorsque l'on redevient maître de sa conscience, ça change tout. Ça change absolument tout. C'est vraiment comme redevenir sobre et retrouver ses moyens. À l'instant où vous réalisez que vous dormez et que vous devenez conscient de la réalité dans laquelle vous êtes vous pouvez faire tout ce que vous voulez. Voler dans le ciel, vous transformer, parler à des personnes oniriques, explorer des lieux et faire l'amour ou n'importe quoi d'autre. Vous êtes libre. Et à moins de l'avoir déjà vécu, il est très dur d'expliquer l'impact démesurément positif que cela a sur le mental, sur notre rapport à la vie en général. Le rêve lucide est en fait une expérience profondément spirituelle et humaine. Le rêve lucide est un sujet de métaphysique. Et il semblerait que le jeu vidéo contribue à faire en sorte que le joueur évolue dans ce sens.

Cette intuition issue de mon expérience personnelle est confirmée par des publications et recherches scientifiques allant également dans cette direction. Jayne Gackenbach est une des représentantes de la recherche sur les effets du jeux vidéos sur les rêves.

Son site internet http://academic.macewan.ca/gackenbachj/ regorge d'expériences témoignant de l'influence du jeu vidéo sur la conscience de soi dans une réalité ou dans une autre. L'idée qui revient souvent est notamment celle que les hardcores gamers seraient moins sujet à des cauchemars et seraient plus à même d'y faire face à l'intérieur même du rêve. Cette aptitude réduit drastiquement le caractère cauchemardesque du rêve et le transforme en une expérience intense de confrontation virtuelle. Tout à fait comme dans un jeu vidéo.

On apprend également en investiguant autour de la chercheuse que ses recherches sur le rapport entre le rêve et le jeu vidéo on commencé quand elle s'est aperçu que son fils après s'être vu offert une Nintendo commençait à faire l'expérience de rêves singuliers. Il est intéressant de noter que la configuration du temps de jeu imparti au jeune garçon par semaine ressemble à s'y méprendre à celle de mon frère et moi plus jeune. Les séances de jeu vidéo n'étaient pas quotidiennes, c'était un moment privilégié et intense comme pour une personne s’adonnant à un entrainement sportif ponctuel mais régulier de sorte à éprouver le corps tout en le laissant récupérer de l'effort.

À l'issue tous deux on co-écrit un livre dont on peut découvrir des extraits ici même http://www.playreality.ca. La démarche, issue de la collaboration du fils et de la mère, n'est pas seulement scientifique, elle est aussi philosophique.

And in a world saturated in movies, television, videogames and gadgets, what we're being exposed to is growing increasingly unreal...

Et dans un monde saturé de films, de télévision, de jeux vidéos et d'autres gadgets, ce à quoi nous sommes exposés tend à devenir substantiellement irréel

Là où je veux en venir, c'est qu'à l'heure où les technologies sont de plus en plus invasives pour le corps et l'esprit, est-il bien raisonnable de prendre le jeu vidéo et la réalité virtuelle uniquement comme de simples objets de divertissement ? Est-ce que ça ne serait pas bien plus que ça ? Est-ce qu'il n'y a pas un enjeu social et culturel au moins aussi significatif qu'a pu l'être l'apparition des premiers réseaux sociaux ? En outre, que va-t-il se passer si la réalité virtuelle dépend de software/hardware propriétaire ?

crossed.png

Avant de parler de réalité virtuelle, il faut déjà bien définir ce que j’entends par là, et rien ne vaut une belle histoire pour illustrer mon propos. À l'époque où je zonais sur le Deepweb, je passais beaucoup de temps sur l'un de ses réseaux sociaux. Il s'agissait d'une sorte de twitter like sur Freenet, appellé Sone. En fait je passais tellement de temps à parler avec des inconnus que je finissais par en rêver la nuit. Cependant, Je ne rêvais pas que je surfais sur ledit réseau social de Freenet confortablement installé devant mon PC, non, je rêvais en « site web ». C'est à dire que dans ce rêve ma seule liberté de « mouvement » était limitée aux interactions possibles par l'ordinateur et par la structure même du site que j'avais l'habitude de visiter dans « le vrai monde réel de la réalité véritable ». Pas d'odeur, pas de sensations de toucher ou de perceptions sonores. Ma vision dans le rêve était strictement bornée à ce que l'on pouvait voir sur un écran, où le navigateur internet aurait été en mode fullscreen. J'étais le pointeur de ma souris. J'étais le petit avatar et le texte de la publication associé et rien d'autre. Ce qui signifie en fait que pendant ce genre de rêves ma réalité devenait littéralement le réseau social que j'avais l'habitude de visiter. Ce n'est pas anodin. Cette expérience est corroborée par mon grand père qui me disait commencer à rêver en noir et blanc lorsque sa famille fit l'acquisition d'un téléviseur en... noir et blanc. Des exemples comme ça, j'en ai à la pelle : Des rêves de différents types de sites internet, de code source, de jeux vidéos en 2D style Super Mario ou même de cinéma en vue à la troisième personne avec des plans très joliment filmés… Ce type de rêves très curieux sont également rapportés par Jayne Gackenbach à mesure qu'elle collecte des témoignages.

La réalité n'est donc pas nécessairement tridimensionnelle, avec des mouvements et de la physique. C'est avant tout de l'information structurée. Et c'est cette information structurée qui éventuellement atteint un degré de complexité tel que l'on puisse parler de « vrai monde réel de la réalité véritable », ou dans sa forme approximée, de jeu vidéo. Quoi qu'il en soit, la réalité est profondément subjective et participe à la construction de notre espace mental, et évidement plus l'on passe de temps l'esprit plongé dans une réalité virtuelle, quel qu'en soit la nature, plus notre espace mental est affecté. En bien ou en mal.

À la lumière de cette définition que je propose on peut maintenant rapidement analyser les différentes proto-réalités virtuelles dont on fait plus ou moins l'expérience chaque jour sans forcément que cela se fasse au moyen du support informatique.

La première de ces proto-réalités virtuelles, et même la plus essentielle selon moi, c'est évidemment le rêve d'une part et d'autre part les moment de rêverie où l'esprit se laisse porter par l'imaginaire dans la journée . Il faut noter ici que l'imaginaire n'est pas formellement créateur. Ce que fait en fait l'imaginaire c'est ré-assembler, mélanger et transformer des souvenirs et de l'information selon des lois procédurales. J'insiste sur le fait que l'imaginaire se construit selon ces lois procédurales parce que pour que la réorganisation et la restructuration des souvenirs puissent aboutir à quelque chose de cohérent il faut des... Algorithmes et des maths. Ce n'est pas si surprenant, le cerveau est bien un super ordinateur analogique et même si son architecture profonde nous échappe encore, l'expérience du rêveur aguerri suggère très fortement que pour générer des univers oniriques, le cerveau doit déployer des algorithmes qui, bien que cela reste encore à déterminer, revêtent nécessairement un aspect fortement structuré. En ce sens le projet UNIVERSE consiste d'une certaine manière à faire rêver l'ordinateur en lui faisant générer des environnements riches en détails à l'aide de structure de données et de lois plus ou moins complexes.

Dans un premier temps, le rêve se fait sur l'unique base de notre expérience vécue. Vient ensuite la littérature. La littérature sollicite très fortement notre imaginaire pour nous plonger dans un univers virtuel orienté et défini par l'objet du texte. Pour pouvoir vivre le récit il faut pouvoir s'immerger dans la représentation mentale que l'on se fait de celui-ci. Cette représentation mentale est nécessairement construite sur des souvenirs arrangés et transformés, comme on l'a dit, pour donner « vie » à l'histoire qui se déroule au fil des pages. Et plus on a d'expériences vécues, plus il est facile de se figurer le récit en soi même ; Ce qui est intéressant c'est que bien que cet imaginaire canalisé ne soit pas interactif, il représente tout de même une forme d'expérience vécue par procuration au moyen de la projection imaginaire que l'on se fait et de la charge sensible intrinsèque au récit ; on évolue avec le ou les personnages, on s'identifie à ceux-là, on vit imaginairement leur joie et leur peine. Ce processus n'est pas trivial parce qu'il participe activement à la construction de la personnalité du lecteur, c'est d'autant plus vrai que le processus d'identification et de mise en situation n'est pas du tout un processus mental passif, il requiert ce qu'on pourrait appeler un « effort intuitif et spontané ». La projection mentale du récit sera une expérience très personnelle et différente d'un lecteur à un autre ce qui rend la lecture d'autant plus singulière.

Entre la littérature et le cinéma, il n'y a qu'un pas, et ça s'appelle la bande dessinée. On ne parlera pas de bande dessinée, mais les effets sont très similaires à ceux du cinéma, le sens de l’ouïe en moins, et l'imaginaire du mouvement en plus. Le cinéma constitue donc selon moi aussi une forme de proto-réalité virtuelle. Elle n'a d'interactif que l'implication émotionnelle du spectateur, et quelle implication ! L'imaginaire est moins sollicité au profit des sens. On voit et on entend, et ce sont sans surprise les deux sens les plus développés chez l'humain. Le cinéma c'est également de l'expérience vécue par procuration et c'est d'autant plus « véritable » et sensible que le jeu d'acteur est performant. Si l'imaginaire semble mit de côté il ne l'est qu'en partie seulement. L'empathie humaine fait que nous sommes capables de ressentir avec une étonnante intensité la condition de nos semblables même si celle-ci est mise en scène et donc factice. Ressentir la condition de l'autre requiert une forme d'imagination sensible très intéressante, quasiment épidermique et viscérale, qui n'est pas ou très difficilement mise à contribution dans la littérature. Avec le cinéma, l'esprit est abusé et se confond un peu plus près avec le monde de fiction qui illumine l'écran. Le cinéma a sur moi un puissant effet catalyseur. Il affecte durablement mon état d'esprit et mon rapport au monde, la personnalité des personnages devient ma personnalité et m'influence comme s'il s'agissait de vraies rencontres.

Ce qui est intéressant avec le jeu vidéo, enfin, c'est qu'il tend de plus en plus vers l'art cinématographique tout en combinant l'interactivité qui le définit en premier lieu. Il y a donc bien une volonté de vivre quelque chose de « réel » au moyen du médium du jeu vidéo tout en sublimant son caractère sensible par la mise en scène pour offrir aux joueurs une expérience de jeu/réalité inoubliable. Chacun de nous à en tête des jeux qui nous ont profondément marqués par leurs qualités artistiques et leur gameplay évolué. En ce qui me concerne, je reste profondément marqué par la série des Silent Hills, la trilogie Dead Space et surtout, mon titre préféré, Mirror's Edge.

Mais tous les jeux n'ont pas pour vocation d'être une expérience sensible, d'autres jeux sont beaucoup plus cérébraux et nous emmènent intellectuellement parlant dans un monde inaccessible dans la réalité de base. Je pense par exemple à Tetraspace, un puzzle game indépendant, gratuit et cross-plateforme où l'on évolue dans un univers en... quatre dimensions spatiales ! Pour la petite anecdote le soir même où j'ai commencé à jouer, j'ai rêvé en... Quatre dimensions ! Comme dans le jeu je pouvais glisser d'un hyperplan à un autre, simplement en le pensant.

minecraftGuy.png

Dans un autre registre de jeux vidéos, le génie de Minecraft, en dépit de son graphisme très minimaliste, est qu'il permet de faire n'importe quoi. Littéralement. La quantité fantastique de mods permet de créer son univers, et même de créer des jeux scénarisés à l'intérieur de Minecraft. On parle de sandbox : des jeux dont la volonté première et de laisser le joueur faire ce qu'il veut. Portez le concept de Minecraft dans un environnement virtuel photo-réaliste et vous avez en partie la réalité virtuelle idéale telle que je me la figure. C'est à dire un environnement procéduralement généré à découvrir et la possibilité de peupler et altérer cet environnement pour mettre en place son propre univers, ou même mettre en scène des histoires et des aventures à l'aide d'outils d'édition. Ça ressemble pas mal à du rêve assisté par ordinateur, et ça me fait rêver justement. Sans doute est-ce pour ça que des podcasteurs sur Youtube ont consacré une émission dédiée à Minecraft et ses incroyables mods, en intitulant sobrement leur show « The Dream ».

Toute la question est de savoir comment peuvent être conçues ces applications pour diminuer substantiellement l'impact négatif et addictif qu'ils peuvent avoir sur l'individu et même, pour aller plus loin, de déterminer comment le jeu vidéo peut devenir un bénéfice pour la société toute entière. Jane McGonigal s’intéresse à la question dans une conférence TED et apporte un début de réponse :

Jane McGonigal : le jeu peut rendre le monde meilleur

Une des choses qui nous viens immédiatement en tête c'est que la plupart des jeux, vidéos ou non, sont construits sur le modèle de la compétition plus que sur celui de la coopération, ce qui est surprenant. L'idée de compétition qui est au cœur du capitalisme semble se perpétuer et s'auto légitimer très profondément dans notre inconscient collectif au moyen des jeux. Si nous commençons à penser les jeux pour nous rendre meilleurs, en plus de nous divertir et de nous sortir de notre réalité, occasionnellement frustrante et bloquante, effectivement, nous pourrions contribuer à sauver le monde de lui même. Jane McGonigal est conceptrice de jeux vidéos, elle expérimente cette idée en développant des jeux conçus de sorte à changer durablement nos habitudes. L'un de ses jeux, « World Without Oil » consiste en une simulation des trente-deux premières semaines de la fin du pétrole au travers d'un contexte mis en scène de manière plausible, richement alimenté, de sorte à rendre la situation vraisemblable. Le but du jeu est de composer avec des informations d'actualité fictives et de vivre sa vie comme si, effectivement, il n'y avait plus de pétrole. Une sorte de jeu de rôle engagé et collaboratif à très grande échelle.

Play it - before you live it

À ce titre il est intéressant de comparer ce genre d'initiatives ludiques avec les dérives du très controversé Second Life, un jeu de réalité virtuelle déjà très avancé pour son temps (2003), mais malheureusement construit sur notre modèle de société et aux antipodes du jeu idéal selon Jane McGonigal. Si le propos de Second Life est... D'avoir une seconde vie, calquée sur notre civilisation, c'est une perte sèche de temps. Au mieux Second Life est un produit dérivé, une pâle contre façon de la réalité. Si l'expérience n'en est pas moins grisante, parce que Second Life permet de créer son monde, de nombreux témoignages lucides s'indignent de l'environnement social nauséabond qu'a engendré cette réalité virtuelle. Le principal problème étant selon moi l'existence d'un système économique profondément et sciemment capitaliste à l'intérieur même de Second Life qui oblige le résident à travailler s'il veut plus d'espace et, de façon générale, plus de trucs évidemment virtuels. Si effectivement on peut rencontrer du monde et découvrir des endroits fabuleux, quelle personne saine d'esprit accepterait de travailler à l'intérieur d'un rêve pour reproduire la condition de la vie réelle? L’absence de scénario et de mise en scène en fait un monde où l'émotion procurée et l'expérience sensible sont totalement dépendants des résidents. Autant dire que si vous êtes entouré de gens malveillants, ça ne va pas le faire du tout. Vu de loin, la structure du jeu, en fait, est conçue pour flatter l'égo. C'est glauque, et c'est bien dommage. Si les podcasts consacrés à Minecraft sont sympa, mais ne volent pas bien haut, ceux consacrés à Second Life sont hallucinants de médiocrité et de perversion. À ce stade de la réflexion il est clair que l'objet qui occupe tant notre temps libre doit être questionné dans sa conception et son propos initial tant celui-ci tend à nous faire développer ce qu'il y a de pire en nous.

Et là, normalement, vous me voyez venir. Si il était plus difficile d'avoir du recul sur l'influence et les effets de la science de la communication dont sont issues la publicité et les stratégies de marketing agressives qui ont profondément marqué notre civilisation, à l'époque de son invention, il est clairement plus aisé en l'an de grâce 2016 d'en évaluer les effets et notamment l'impact que cela a eu dans la conception des objets, des médias et mêmes des œuvres qui nous entourent, et donc à fortiori sur notre imaginaire et notre mental duquel découlera une vision du monde bien particulière.

vaporHead.png

À ce titre j'ouvre une parenthèse à propos d'un mouvement artistique que j’affectionne né dans le début des années 2010, souvent considéré comme accelerationiste, et qui pose notre civilisation en rétrospective en traduisant en image et en musique ce qu'on pourrait appeler le mensonge capitaliste. Ce mouvement appelé Vaporwave est sujet à beaucoup d'interprétations mais l'idée est en partie d'explorer l'impact de notre société consumériste sur notre imaginaire collectif en créant un fort sentiment de nostalgie onirique ou au contraire en parodiant les codes de la publicité et du marketing agressif en amenant la logique esthétique de ceux là dans des proportions psychédélico-épiléptique délirante. Tout un programme !

Dans un article pour DUMMY, intitulé Vaporwave and the pop-art of the virtual plaza, Adam Harper introduit son sujet ainsi

Global capitalism is nearly there. At the end of the world there will only be liquid advertisement and gaseous desire. Sublimated from our bodies, our untethered senses will endlessly ride escalators through pristine artificial environments, more and less than human, drugged-up and drugged down, catalysed, consuming and consumed by a relentlessly rich economy of sensory information, valued by the pixel. The Virtual Plaza welcomes you, and you will welcome it too.

Le Capitalisme global est presque là. À la fin du monde il n'y aura plus que des publicités liquides et des désirs gazeux. Sublimé en dehors de nos corps, nos sens libérés parcourront sans fin des escalators au travers d'environnements purement artificiels. Plus ou moins humains, en plein bad trip ou au contraire bien perché, catalysé, consummant et consommé par une infatigable et prospère économie de l'information sensorielle portée par le pixel. La place virtuelle vous accueille et vous l’accueillez également.

Et si ça ne vous aide pas à vous représenter le délire, rien ne vaut un bon exemple de Vaporwave, produit par Oneohtrix Point Never, un pionnier du genre.

Angel - Oneohtrix Point Never

Si vous explorez d'avantage le mouvement Vaporwave et les sous genres auxquels il a donné naissance on se rend compte que la réalité virtuelle est un thème récurant dans la mise en évidence du caractère contrefait de la société et le questionnement qu'il pose par rapport à la technologie et l'informatique. Où voulions-nous aller en tant que consommateur? Où sommes nous aujourd'hui? Le Vaporwave, en fait, n'est pas si éloigné du cyberpunk dans ses thématiques.

Fin de la parenthèse. Si la réalité virtuelle fait encore l'objet d'expérimentations et de recherches elle n'en est pas moins mature dans son aspect fonctionnel. Pour autant, bien que ces technologies vont être amenées à se démocratiser dans notre quotidien déjà en substance profondément irréel il est important d'en prendre la juste mesure et notamment sur l'aspect très intéressant de la modernité néo-libérale illustrée par le Vaporwave. En gros, faites confiance aux entreprises qui vous vendent du rêve. Peu importe que vous ayiez ou non le contrôle sur l'objet de vos désirs. La technologie est érigée en sacré. Inviolable et inaltérable. Pourquoi voudriez vous donc trafiquer votre Mac ou votre surface book qui fonctionne déjà très bien dans les limites et les normes d'interopérabilité fixées par le constructeur ? Peu importe de toute façon, un nouveau modèle arrive très prochainement...

Un outil, un objet, ou même la réalité virtuelle, c'est exactement comme le langage. Vous pensez en fonction des mots, et pas l'inverse. Autrement dit si par exemple votre système d'exploitation est spécifiquement conçu pour être ergonomique, facile d'utilisation et intuitif, cela implique de fait que celui-ci vous rende bête sur le long terme, ou du moins, ça ne vous rendra pas plus intelligent et créatif. Vous serez sans esprit fonctionnel critique puisque vous n'aurez pas d'effort cognitif à fournir. Et en particulier, sur le long terme, vous serez dépossédé de la complexité potentielle de l'outil, désarmé devant un terminal ou une interface avec plus de trois boutons. Le libre usage ne vous appartient plus vraiment. Vous ne serez plus capable de penser l'objet autrement que dans le cadre de sa fonction première. Windows 10 en est un parfait exemple: conçu initialement pour des appareils tactiles on se rend bien compte que c'est extrêmement limitant au regard de ce que peut vraiment faire un ordinateur classique. J'ai bien conscience que cette affirmation est discutable mais mon idée c'est que l'intrusion de l'informatique dans tous les aspects de notre vie nous a paradoxalement éloignés de ce qui en fait l'essence et la noblesse. En outre mon problème n'est pas la simplification de l'informatique, mais le fait que son usage en soit rigoureusement et méthodiquement bridé.

La complexité fonctionnelle d'un outil doit normalement augmenter à mesure que celui-ci permet de réaliser des choses complexes. Autant dire qu'avec l'informatique grand-public d'aujourd'hui on ne peut plus strictement rien faire. Ce n'est plus qu'un gadget amusant et moteur d'innovations insipides. Tout est filtré et sous contrôle. Si vous ne voyez toujours pas le problème dites vous que l'histoire a montré qu'il n'y a pas besoin d'être ingénieur en informatique pour faire de l'informatique comme un virtuose créatif et prolifique. C'est parce qu'à une certaine époque la conception même de l'outil informatique était, volontairement ou non, ouverte à une certaine expérimentation et appropriation intime de l’utilisateur. À titre d'exemple, et j'en rêve encore, j'ai commencé à programmer pour la première fois spontanément en comprenant intuitivement ce que je faisais avec un ordinateur qui a aujourd'hui vingt ans. Mes premiers programmes étaient en Quickbasic, livré par défaut avec Windows 3.1, et c'était merveilleux. Je ne serais pas qui je suis sans ça. C'était déterminant. Pour programmer ou bidouiller à l'âge de 12 ans, il n'y a pas besoin d'être un génie. Il faut juste avoir l'outil entre les mains, et ça va tout seul ensuite.

Il y a quelques semaines dans un café, avec mon père et un ami à lui, je parlais avec son fils du même âge que moi lorsque j'ai commencé à aligner mes premières lignes de codes. Le petit était sur smartphone sans arrêt et est fan de jeux vidéos. Des jeux plus réalistes et riches que jamais. On vit une époque ou l'informatique est littéralement devenue de la sorcellerie et ou la sorcellerie n'a jamais été aussi accessible pour peu qu'on fasse l'effort de chercher. Quand j'expliquais au petit l'enthousiasme qui anime ma passion de l'informatique, les opportunités professionnelles qui pouvaient s'offrir à lui s'il commençait dès maintenant à s'emparer de la magie du code, le gamin avait l'air emballé à mort, tout comme son père, prêt à entreprendre le prochain kickstarter révolutionnaire et finir millionnaire à vingt ans. Je le redirigeais vers des tutoriels d'Open Classroom ou les passionants challenges de hacking de Newbie Contest sur lesquels j'ai moi même tant appris.

Quelques jours plus tard j'apprenais que notre futur millionnaire avait lâché l'affaire et que la flamme s'était éteinte, préférant revenir à un mode d’existence plus passif et normé. Et il ne faut pas se mentir à soi même, cette attitude est le pur produit de notre époque. Les jeunes ne pensent plus à coder ou bidouiller, pas parce que c'est dur mais parce que ce n'est pas une option par défaut ou une fonctionnalité intégré spontanément aux systèmes qu'ils utilisent, comme si, finalement, bidouiller était devenu quelque chose d'illégitime dans l'esprit des consommateurs. Et ça commence dès l'enfance. Avant j'avais un IDE par défaut et un manuel, gracieusement mis à disposition par Microsoft pour coder en QuickBasic et faire ce que je voulais de mon i386 à 4Mo de Ram. Maintenant il faut faire des pieds et des mains pour déverrouiller un smartphone ou une tablette aux CPUs à quatre cœurs de chacun 2,5 Ghz. C'est pas malheureux ça franchement? Heureusement il y a Raspberry Pi et Arduino. Mais c'est vachement moins sexy quand il faut tout faire depuis le début. Le fait de pouvoir modifier et transformer les choses est un moyen extrêmement efficace et rapide d'apprendre, c'est beaucoup plus gratifiant et rapide que de tout reprendre depuis le début avec des composants électroniques obscurs.

Un autre article spécifiquement écrit sur ce sujet démontre le pouvoir de la technologie, en particuliers l'informatique, à construire qui nous sommes :

Combien de futurs hackers Apple est-il en train de tuer ?

Le fait est encore une fois que si nous laissons construire par les entreprises et le marché une civilisation qui par la force des choses nous détermine en tant qu'individu, alors on est foutu. Et là on parle seulement d'objets de la vie de tous les jours, on ne parle pas encore de réalités virtuelles qui ne sont pas encore démocratisées. Toute cette logique capitaliste amène la technologie, qui aurait du libérer l'humain de sa condition et l'épanouir, à l'aliéner au contraire physiquement et mentalement. Je pourrais encore m'étendre sur de longs paragraphes là dessus en évoquant comment par exemple, en termes d'éducation, internet a révolutionné la diffusion de l'information qui n'est plus verticale (télévision, radio, média de masse), mais horizontale (vulgarisation, podcast, blog, open-source et culture libre). Je pourrais dire que ça ne va pas de soit et que c'est un combat de tous les jours et que même maintenant la culture populaire prétendument libérée de l'élite est infiltrée et corrompue par la publicité et le placement de produit, que ce soit sur Youtube, Tumblr ou n'importe quel autre média. Le capitalisme a faim et l'argent ne dort jamais.

Hier nous partagions et vulgarisions le savoir critique. Demain ce pourrait être de l'expérience critique que nous partagerions dans une grande simulation collective.

La réalité virtuelle est profondément intrusive, plus que tout autre chose elle a le potentiel de façonner la personnalité parce que nous sommes ce que nous vivons. Je ne serais pas la même personne sans les jeux vidéos et sans mon vieux i386. Tout mon travail artistique – aussi modeste soit-il – tourne autour du rêve grâce à ces deux composantes épisodiques mais déterminantes de ma vie. C'est parce que l'un m'a fait prendre conscience de moi même en dehors de la réalité physique, en vivant par procuration et interactivement des aventures, et l'autre m'a fait prendre en main la magie derrière ce que j'aime appeler le rêve assisté par ordinateur.

On pourrait alors rétorquer que pourtant, les jeux vidéos qui ont participé à faire de moi la personne que je suis étaient des produits d'entreprises qui avaient la mains mise sur leurs œuvres. Mais justement, ce n'était pas exactement de la réalité virtuelle comme le progrès et l'économie vont nous y amener, c'était des œuvres à part entière, ludiques et interactives, avec une histoire qui impliquait émotionnellement le joueur, c'était des sortes de proto-réalités virtuelles. Je ne jouais pas à FIFA mais à Zelda ou Mario. Ces œuvres ont bien plus marqué mon imaginaire que d'autres jeux parce qu'ils avaient une valeur artistique et sensible qui me touchait profondément. Les nouveaux jeux vidéos sont pour la plupart plus artistiques que jamais, ce n'est pas ce que je remets en cause, quoique cette qualité tend également à souffrir de la logique économique en amont (la licence Silent Hill en est un triste exemple). Ce que je questionne ici c'est le fait qu'avec le temps les frontières de la réalité vont tendre à se brouiller. La réalité va rencontrer l’œuvre de fiction interactive et l'expérience sociale va s'étendre à des espaces virtuels potentiellement infinis. Potentiellement seulement, parce que factuellement nous dépendons d'une élite de programmeur ou d'industriels qui définiront et limiteront pour nous ce monde encore à découvrir. Et la horde passionnée de libristes militant contre GAFAM ne fait malheureusement pas le poids pour le moment.

markLol.png

Le fait de savoir lire implique normalement de savoir écrire, normalement... La culture entretient la culture et produit les nouveaux artistes qui feront s'étoffer cette culture. De même, quand internet est apparu la création de sites internet était une science à part entière et soudain des gens ont eu l'idée brillante de créer des blogs et donc de rendre accessible à chacun cet outil. Chacun donc a pu créer son site internet facilement tout en le personnalisant. La blogosphère et tous les outils d'édition en ligne ont permis à internet de devenir un univers protéiforme, coloré est improbable sur bien des aspects. Il est triste de remarquer l'inquiétante influence de Facebook sur ce monde idyllique. Quand tout le monde existait virtuellement sur son Skyblog finement personnalisé, qui constituait en soi déjà à l'époque une forme de réseau social il était parfaitement improbable que les utilisateurs migrent en masse vers Facebook qui en l'état ne faisait rien de nouveau. Il ne faisait que combiner dans un espace glacial et normé deux fonctionnalités déjà bien encrées dans le quotidien des internautes ; à savoir le fait de bloguer et poster du contenu sur le web via des blogs et de tchatter en direct sur MSN, Skype ou Caramail. Je ne m'explique donc pas le succès de cette plateforme dont la seule innovation fut et est encore de centraliser les choses…

Récemment Facebook a évoqué le fait d'intégrer la réalité virtuelle à son réseau social, c'est plutôt commode quand on sait que celui-ci a racheté Oculus Rift, flairant le gros poisson. La réalité virtuelle s'inscrit potentiellement comme on s'en doute dans une logique de contrôle politique et économique. Facebook qui décidément n'a peur de rien faisait récemment beaucoup parler de lui quant à sa politique concernant la vie privé de l'utilisateur dans le cadre de son utilisation de l'Oculus Rift. Oculus Rift has some shady stuff in their terms & privacy policy

L'intrusion dans l'intimité atteint des sommets inégalés. Ça veut bien dire ce que ça veut dire. Là où la réalité virtuelle pourrait être une technologie se déployant anarchiquement avec ses propres codes culturels et outils de création d'univers, nous allons vers une centralisation et une normalisation de celle-ci au profit de quelques grosses compagnies dont il est peu probable que celles-ci pensent à notre épanouissement. Ça n’empêchera pas un ailleurs, une alternative, mais pourquoi faudrait-il toujours que l'open-source et la culture libriste soit systématiquement en marge ? C'est ça le truc. Il faut ABSOLUMENT que nous nous démarquions en nous accaparant dès maintenant la réalité virtuelle. Ce n'est pas un caprice ou une lubie idéaliste. Si ce n'est pas moi qui le fais, il faut que d'autres le fassent. Sinon nous allons, littéralement, vers le monde sans substance tel que représenté dans le Vaporwave. Une matrix nébuleuse et consumériste où nous errons sans but d'un univers étincelant de publicités à de mornes journées de travaux forcés.

Concluons en posant que si la réalité virtuelle n'est pas un objet d'émancipation, il sera fatalement un outil d'aliénation. Cela étant dit on se quitte sur une citation de Richard Linklater extraite de son incroyable film Waking Life:

It's bad enough that you sell your waking life for.. for minimum wage, but now they get your dreams for free.

C'est déjà assez moche qu'on vende sa vie éveillée... pour le salaire minimum... mais maintenant, ils s'emparent gratuitement de nos rêves.

Quelques liens sympa qui ont servis de documentation pour cet article

Un grand merci à Benjamin Loubet pour la relecture et les suggestions! Merci également à Sidoine pour la suggestion de liens!

Génération procédurale de terrain: Part. II, Stamp Noise

Avant d'entamer cette seconde publication sur la génération procédurale, je vous propose encore une fois d'introduire quelques nouveaux termes qui seront utilisés ici.

  • Récursivité : La récursivité désigne en algorithmie le procédé par lequel une fonction donnée s'appelle elle-même. Un algorithme récursif s'oppose donc à un algorithme itératif qui, lui, répétera un nombre déterminé de fois un bloc d'instructions. Bien que tout problème résolu avec une approche récursive puisse être implémenté sous une forme itérative, et vice-versa, il est parfois clairement plus commode d'opter pour l'une ou l'autre solution selon le contexte. Dans tous les cas, chacune de ces approches nécessitent une condition d'arrêt.
  • Voxel: Un voxel est dans l'espace tridimensionnel ce qu'est un pixel sur une surface plane. Bien que son utilisation soit rare de nos jours dans le jeu vidéo, on le rencontre souvent en imagerie médicale.
  • Complexité algorithmique: Je vous renvoie à l'article que j'ai écrit sur le sujet.
  • Artefact: Cela désigne plusieurs choses, mais la plupart du temps, l'idée reste la même quel que soit le contexte. En imagerie de synthèse cela fait référence aux défauts visibles sur une image produite par un algorithme. Dans notre cas si l'on cherche à créer des surfaces procédurales avec pour ambition de leur donner un aspect naturel, alors on appelle artefact tout ce qui, sur ces surfaces, trahit le caractère artificiel de l'image. Typiquement, la présence de motifs se répétant, des formes géométriques visibles ou bien encore des distorsions topologiques anormales. Ces défauts peuvent être la conséquence d'un bug dans l'implémentation de l'algorithme ou un défaut dans la conception de l'algorithme lui-même. La précision des nombres à virgule flottante peut également produire ce genre d'effets.

Pour ce second article nous allons traiter d'un algorithme naïf, mais qui fonctionne bien. L'idée générale est tellement simple et facile d'implémentation qu'elle n'a pas vraiment de nom et n'est pas formellement décrite, à ma connaissance en tout cas. En cherchant un peu sur internet le procédé a cependant déjà été évoqué, notamment ici:

Creating Spherical Worlds

En ce qui me concerne, et pour cet article nous parlerons, de Stamp Noise.

Posons un peu le contexte pour bien comprendre et introduire le concept avant de commencer. Dans les années 80 nous n'avions pas encore de machines aussi puissantes qu'en l'an de grâce 2016. Ça tombe sous le sens. Lorsque l'on voulait générer un terrain, on le faisait généralement à partir d'une source de données volumineuse -une texture- qui devait être ensuite transformée en une surface en trois dimensions. Ken Perlin a révolutionné la technique avec son algorithme qui avait notamment la prodigieuse capacité de créer des surfaces à la volé, épargnant substantiellement la bande passante et la mémoire au point que ce genre de surface pouvait éventuellement être générée en temps réel quelques années plus tard. Mais ce n'est pas tout, ce genre de technique permet de s'affranchir, dans une certaine mesure, des problèmes que posent le placage de texture sur un objet 3D complexe: Plutôt que de générer une surface qu'il faut ensuite mapper sur l'objet en 3D, on peut directement travailler sur les vertices ou créer les voxels constituant cet objet quand, par exemple, l'algorithme de Perlin est utilisé en trois dimensions.

Entre temps, la technologie ayant évolué, et n'ayant plus les mêmes contraintes techniques, nous pouvons envisager la génération procédurale sensiblement différemment. Le premier problème que pose l'algorithme de Perlin, c'est qu'il n'est pas spécialement rapide. La faute à l'interpolation et le produit scalaire des vecteurs. Je vous renvoie à mon précédent billet pour vous rafraîchir la mémoire. L'autre problème majeur c'est que les terrains produits avec le Bruit de Perlin, quoique convainquant, sont très neutres. Il n'y a pas d'aspérités ou d'érosions qui confèrent au terrain un caractère particulier.

La stratégie que l'on peut envisager, à la lumière de l'éblouissante puissance de nos ordinateurs, c'est de limiter les calculs en stockant des motifs d'aspérités dans une matrice. On calcule une première fois un motif, et on l'imprime successivement sur notre champ de hauteur (heightmap) vierge, en répétant le procédé à différentes échelles. Ce qui est intéressant dans cette approche, c'est que l'on peut générer ainsi plusieurs tampons. Soit en les générant procéduralement, soit en les chargeant depuis une image. Le bénéfice immédiat c'est un gain substantiel de rapidité puisque l'on effectue les opérations mathématiques, éventuellement, durant la création des tampons et non plus dans la phase critique de la création de la heightmap. Par ailleurs, ces tampons peuvent être stockés durablement dans la mémoire pour être réutilisés, évidemment, à différentes échelles. L'autre bénéfice, c'est que les tampons seront potentiellement très différents et contribueront à donner à notre terrain une certaine personnalité.

Maintenant que nous avons introduit le sujet, nous allons rentrer un peu plus dans le détail de l'algorithme. Dans cet article nous distinguerons deux phases:

  • La création du tampon.
  • L'application du tampon.

J'avais dit tout à l'heure qu'il serait intéressant d'avoir plusieurs tampons. Pour cet article cependant, nous ne détaillerons qu'un seul tampon très simple. Un cône. C'est ce que nous allons voir maintenant.

Le rayon de notre cône aura une longueur de (2^n)/2 soit en fait 2^(n-1), avec n un nombre entier qui déterminera l'aire de notre surface. La technique consiste à parcourir une matrice de 2^(2n) éléments. Pour chaque points de la matrice on calcule le rayon du cercle imaginaire passant par le point courant. Ce cercle est sans surprise centré exactement au milieu de la matrice.

tampon1.png

Pour calculer le rayon, rien de plus simple, on utilise la mythique, ancestrale et légendaire relation de Pythagore.

notons

R = 2^(n-1)

le rayon du cercle inscrit dans le carré que forme le tampon. On a alors la relation suivante

r = √( (x-R)² + (y-R)²)

Une petite optimisation s'impose cependant. Cette opération va être répétée 2^2n fois, mais on peut d'abord pré-calculer dans une table de longueur n les puissances de deux de 0 à n-1. Ce qui allégera substantiellement nos calculs.

radius = sqrt( powersOfTwo[x] + powersOfTwo[y] )

Une fois le rayon calculé en un point donné on peut attribuer en ce point du tampon une valeur telle que celle-ci soit comprise entre 0 et 1.

tampon[x][y] = (halfScale - radius ) / halfScale

Voilà, nous sommes bon pour le tampon! Maintenant, il faut l'appliquer!

Globalement, c'est très simple. Notre algorithme est récursif, et à chaque appel, la fonction de bruit travaille sur un secteur, puis subdivise ce secteur en quatre tout en se rappelant quatre fois elle même à nouveau, et ainsi de suite, tant que le secteur à venir a une taille de deux au moins.

sectors.png

Pour chaque secteurs courants on centre le tampon mit à l'échelle. Comme on travaille avec des puissances de deux il est facile pour une coordonnée donnée de trouver sa correspondance dans le tampon.

inc = realScale / scale

où realScale est la dimension réelle de notre tampon/heightmap et où scale est la dimension du secteur courant. En parcourant dans une double boucle notre heightmap en un secteur donné, à chaque itération en x et en y on incrémente respectivement les coordonnées x et y relatives au tampon par inc.

Notons par ailleurs que notre double boucle ne parcourt pas toute la heightmap. Elle parcourt un SECTEUR de la heightmap. Nous aurons donc quelque chose de la forme

        ...
	for(x=0;x<scale;x++) {
	  stampY = 0;
	    for(y=0;y<scale;y++) {
              heighmap[x+offsetX][y+offsetY] += tampon[stampX][stampY];
	      stampY+=inc;
	    }
	  stampX+=inc;
	}
        ...

scale correspond évidemment ici à la taille du secteur courant.

Le cœur du bruit va être maintenant de déplacer le tampon sur son secteur. On n'y est pas obligé, mais dans notre implémentation, quand le tampon dépasse de la heightmap, on reboucle de l'autre côté de celle-ci. Pour déplacer le tampon on se donne deux valeurs pseudo-aléatoires de déplacement en x et en y.

randX = - halfScale + randomInteger % scale;
randY = - halfScale + randomInteger % scale;

avec scale la dimension de notre secteur courant et halfscale la demi dimension du secteur courant. randomInteger est évidement un nombre aléatoire dont la récupération est laissée à la discrétion du programmeur.

displacement.png

La boucle principale devient donc quelque chose de la forme

        ...
	for(x=0;x<scale;x++) {
	  stampY = 0;
	    for(y=0;y<scale;y++) {
              heighmap[x+offsetX+randX][y+offsetY+randY] += tampon[stampX][stampY];
	      stampY+=inc;
	    }
	  stampX+=inc;
	}
        ...

Dans ce pseudo code qui ressemble à s'y méprendre à du C/C++ on ne gère pas encore le cas où les sommes composants les coordonnées x et y dépassent positivement ou négativement de la heightmap. Il faut donc prévoir le coup et distinguer le cas simple où le tampon est bien contenu à l'intérieur de la heightmap et le cas plus rare où le tampon dépasse sur au moins un axe.

Un autre test optimisant substantiellement le nombre d'instructions à exécuter est celui de la valeur locale du tampon. Si cette valeur vaut zéro alors il est inutile de calculer les coordonnées, les offsets, les dépassements, les tests et autres affectations. En procédant ainsi on effectue approximativement 1.27 fois moins d'opérations dans le cas où le tampon est un cône. C'est propre!

Enfin, pour terminer, à chaque appel récursif on choisit pseudo-aléatoirement le type d'opération sur la heightmap. Est-ce qu'on creuse le terrain? Ou est-ce qu'on le surélève? Pour ce faire la valeur locale du tampon est multipliée par une variable qui vaut soit un ou moins un et qui est déterminée avant la double boucle principale.

// sign, c'est ce que je viens d'expliquer plus haut, à l'instant.
// inc divise le bruit courant, comme on en a déjà parlé, pour diminuer l'influence du bruit en fonction de l'octave.
heightmap[x][y] += sign * currentStampValue / (inc);

Voilà pour les grandes lignes! Je vous propose de jeter un œil à l'implémentation que j'ai faite et même de tester ça chez vous en récupérant les sources complètes ici, sur github: stampNoise

#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <math.h>
#include "png.cpp"

// Le générateur de nombres pseudo aléatoires.
unsigned long int getRandom() {
        timespec tStruct;
        clock_gettime(CLOCK_REALTIME, &tStruct);
        return tStruct.tv_nsec;
}

// Le générateur de tampon
void UNIVERSE_STAMP_1(double * matrix, int scale) {
	int x,y,X=0;
	double halfScale = scale / 2 ;
	double radius;

        int * powersOfTwo = (int *) malloc( sizeof(int) * scale);

        // On crée deux tables contenants les valeurs élevées à la puissance de deux.
        // On calcul ainsi n fois ces valeurs au lieu de n².
        for(x=0; x<scale;x++) {
          powersOfTwo[x] = (x-halfScale) * (x-halfScale);
        }

        // Plutôt que d'incrémenter d'une unité x et calculer la position du point courant 
        // dans le tableau en multipliant par scale, on incrémente directement x par scale. La formule
        // pour retrouver le point courant n'est plus 
        // 
        // x*scale +y 
        //
        // mais 
        //
        // x+y
        //
        // Il faut donc également penser à changer la valeur limite de la première boucle
        int limit = scale*scale;
	for(x=0;x<limit;x+=scale) {
		for(y=0;y<scale;y++) {
                        // On calcule le rayon du cercle sur lequel se trouve le point courant.
                        // Opération très TRÈS gourmante en temps CPU
			radius = sqrtl( powersOfTwo[y] + powersOfTwo[X]);
			if ( radius < halfScale ) {
                                // y a plus qu'à dessiner le cône.
				matrix[x+y] = (halfScale - radius) / (halfScale);
			}
			else {
                                // Si on est en dehors du cercle, on se casse pas la tête et on affecte un zero.
				matrix[x+y] = 0;
			}
		}
                //Comme x est incrémenté par scale, et qu'on doit quand même accéder à notre tableau powersOfTwo...
                X++;
	}
        free(powersOfTwo);
}

void UNIVERSE_STAMP_NOISE(double * matrix, double * stamp, int scale, int offsetX, int offsetY, int realScale) {

        // La condition d'arrêt de notre bruit récursif.
        // Selon la granularité que l'on désire, on peut augmenter la valeur limite de scale.
        if (scale == 1) {
		return;
	}

        // Demi dimension courante
        // Comme scale est une puissance de deux, plutôt que de diviser, on opère une rotation binaire.
	int halfScale = scale >> 1;
	int x, y;

        // Deux variables très importantes, ce sont elles qui déterminent ou sera appliqué le tampon.
        // C'est le positionnement aléatoire qui fait toute la "beauté" de la heightmap.
	int randX = - halfScale + getRandom() % scale;
	int randY = - halfScale + getRandom() % scale;

        // À chaque octave il faut diminuer l'influence du bruit.
	// On se sert également de cette variable comme pas d'incrémentation des
        // coordonnées du tampon.
        int inc = realScale / scale;

        // Deux variables incrémentales qui servent à récupérer le pixel locale au tampon, en fonction de l'octave.
        // Elles sont toute les deux incrémentés avec la valeur de inc.
	int stampX=0, stampY=0;

        // Nouvelles coordonnées si dépassement du tampon en dehors de la heightmap
	int wrapX,wrapY;

        // Détermine le signe du tampon.
        // S'il est positif, le terrain se surélève, à l'inverse, il se creuse
	float sign = getRandom() & 2 ? -1.0 : 1.0;

        int tmpCoordX,tmpCoordY;
        double currentStampValue;

	for(x=0;x<scale;x++) {
	  stampY = 0;
	    for(y=0;y<scale;y++) {
              // On économise des calcules fastidieux en stockant cette valeur qui sera solicitée au moins une fois.
              currentStampValue = stamp[stampX*realScale+stampY];

              // Avec ce test le gros bloc d'instructions est répété 1.27 fois moins que s'il n'y avait pas de test.
              if (currentStampValue != 0) {
                // On économise des calculs fastidieux en stockant ces valeurs qui seront solicitées plusieurs fois.
                tmpCoordX = randX + offsetX + x;
                tmpCoordY = randY + offsetY + y;

                // Le cas simple où le tampon ne dépasse pas de la heightmap
	        if ( tmpCoordX < realScale && tmpCoordX >= 0 && tmpCoordY < realScale && tmpCoordY >= 0) {
	          matrix[ (tmpCoordX * realScale) + tmpCoordY] += sign * currentStampValue / (inc);
	        }

	        // Là c'est plus pénible, il faut calculer le décalage à appliquer selon le ou les côtés où le pixel à dépassé.
	        else {
                  // On restore les coordonnées du décalage
                  // Comme il se peut que le pixel ne dépasse que sur un axe, par défaut, le décalage est fixé à zero.
		  wrapX = 0;
	    	  wrapY = 0;
                  // Le pixel dépasse à droite
		  if ( tmpCoordX >= realScale && tmpCoordX < realScale*2 ) {
      		    wrapX = - realScale;
		  }

                  // Le pixel dépasse à gauche
		  else if ( tmpCoordX > -realScale && tmpCoordX < 0) {
		    wrapX = realScale;
	  	  }

                  // Le pixel dépasse en haut
		  if ( tmpCoordY > -realScale && tmpCoordY < 0) {
    		    wrapY = realScale;
		  }

                  // Le pixel dépasse en bas
		  else if ( tmpCoordY < realScale * 2 && tmpCoordY >= realScale) {
		    wrapY = -realScale;
		  }

                  // On peut maintenant repositionner le pixel sur la heightmap.
                  // la coordoonée final dans un tableau simulant une matrice est de la forme:
                  //
                  // (X * hauteur) + Y
                  // Avec X valant la somme du 
                  //  décalage du secteur courant en abcisse, 
                  //  du décalage du tampon courant en abcisse,
                  //  la coordonnée x courante,
                  //  le décalage aléatoire en abcisse
                  //
                  // Avec Y valant la somme du 
                  //  décalage du secteur courant en ordonnée, 
                  //  du décalage du tampon courant en ordonnée,
                  //  la coordonnée y courante,
                  //  le décalage aléatoire en ordonnée
		  matrix[ (  wrapX + tmpCoordX) * realScale + tmpCoordY + wrapY] += sign * currentStampValue / (inc);
      	        }
              }
              // On incrémente à l'échelle la coordonnée locale au tampon
	      stampY+=inc;
	    }
          // On incrémente à l'échelle la coordonnée locale au tampon
	  stampX+=inc;
	}

        // En divisant par deux la dimension courante à chaque récursion, et en modifiant l'offset,
        // on subdivise en permanence la heighmap jusqu'à ce que la dimension ainsi divisée soit égale à un.
        // En procédant ainsi, on travaille récursivement sur différentes
        // portions de la heighmap. Il y a donc quatre portions par secteur et à chaque récursion, chacunes
        // des portions devient elles même un secteur.

        // Portion en haut à gauche du secteur courant
	UNIVERSE_STAMP_NOISE(matrix, stamp, scale/2, offsetX+0, offsetY+0, realScale);

        // Portion en bas à gauche du secteur courant
	UNIVERSE_STAMP_NOISE(matrix, stamp, scale/2, offsetX+0, offsetY+scale/2, realScale);

        // Portion en bas à droite du secteur courant
        UNIVERSE_STAMP_NOISE(matrix, stamp, scale/2, offsetX+scale/2, offsetY+scale/2, realScale);

        // Portion en haut à droite du secteur courant
	UNIVERSE_STAMP_NOISE(matrix, stamp, scale/2, offsetX+scale/2, offsetY+0, realScale);
}

int main(int argc, char ** argv) {
	if (argc == 1) {
		std::cout << "Usage: ./heightMap <power of two>\n";
		return 0;
	}

	int scale = atoi(argv[1]);
	int x,y,i,j;
	double * matrix;
	double * stamp;
	matrix	= (double *) malloc( sizeof( double ) * scale * scale);
	stamp 	= (double *) malloc( sizeof( double ) * scale * scale);
        PIXEL ** png = (PIXEL **) malloc(sizeof(PIXEL *) * scale);

        // On s'assure que la matrice de destination sera bien "vierge"
	for (x=0; x<scale;x++) {
		for(y=0;y<scale;y++){
			matrix[x*scale+y] = 0;
		}
		png[x] = (PIXEL *) malloc(sizeof(PIXEL) * scale);
	}

        // On génère d'abord notre tampon
	UNIVERSE_STAMP_1(stamp, scale);

        // On lance notre bruit récursif.
        // On conmmence la récursion avec l'octave la plus grande.
	UNIVERSE_STAMP_NOISE(matrix, stamp, scale, 0, 0, scale);

        // À partir d'ici, la heightmap est terminé. Il n'y a plus qu'à déterminer les extremums
        // pour normaliser la hauteur.

        long double max=0,min = 65536;
        for (x=0; x<scale;x++) {
                for(y=0;y<scale;y++) {
                        if (matrix[x*scale+y] > max) {
                                max = matrix[x*scale+y];
                        }
                        if (matrix[x*scale+y] < min) {
                                min = matrix[x*scale+y];
                        }
                }
        }

        char color;
        for (x=0; x<scale;x++) {
                for(y=0;y<scale;y++) {
                        color = (((matrix[x*scale+y]) - min ) * 255) / (max - min);
                        png[x][y].Alpha = 0xFF;
                        png[x][y].Red = color;
                        png[x][y].Green = color;
                        png[x][y].Blue = color;
                }
        }

        // On transfére notre heightmap dans un fichier png...
        writePng(png,scale);
	return 0;
}

Et donc, qu'est-ce que ça donne quand on passe la heightmap ainsi générée en postprod?

stampNoise0x03.png

stampNoise0x02.png

stampNoise0x01.png

C'est tout à fait convaincant! Cependant l'algorithme, quand il est utilisé avec un cône uniquement, produit des artefacts... En effet on peut voir apparaître, si on est attentif, des cercles trahissant l'usage d'un objet circulaire pour modeler le terrain.

artefact.gif

Pour le moment, ce n'est pas le drame, mais il peut être intéressant, voir nécessaire, d'utiliser d'autres formes de tampons. Dans le cas du cône une possible solution pourrait être de combiner la formule d'interpolation du bruit de Perlin amélioré avec celle utilisée pour produire le cône:

tampon[x][y] =  interpolation ( (halfScale - radius ) / halfScale)

rappelons que la formule d'interpolation utilisée dans le bruit de Perlin amélioré est

6t⁵- 15t⁴ + 10t³

Cela aurait pour effet d'adoucir les transitions entre la base du cône et son sommet. Cône qui du coup n'en serait plus un et qui deviendrait plutôt une sorte de cloche.

Maintenant qu'en est-il du temps d'exécution et de la complexité de notre algorithme? Toujours sur un core i3, je mesure:

  • 3ms pour 128 pixels²
  • 8 ms pour 256 pixels²
  • 30ms pour 512 pixels²
  • 123ms pour 1024 pixels²
  • 530ms pour 2048 pixels²
  • 2530ms pour 4096 pixels²

C'est beaucoup beaucoup mieux que l'algorithme de Perlin! Pour un n donné avec le Bruit de Perlin on peut générer une surface deux fois plus grande avec approximativement le même temps! En ce qui concerne la complexité du Stamp Noise je la définis ainsi:

C(n) = 2^n + π.(n+1).(2^(2n))/4

Graphiquement ça donne

theoreticalStampNoiseComplexity.png

Et quand on mesure les temps effectivement mesurés en millisecondes, toujours en fonction de n

experimentalStampNoiseComplexity.png

On est dans les clous!

Merci à Théo Fish et Benjamin Loubet pour la relecture!

Complexité Algorithmique

Dans le dernier article nous nous quittions notamment sur la question de savoir s'il est possible de mieux faire ? Mais je n'ai pas dit par rapport à quoi. Il y a deux choses qui nous intéressent. La rapidité d'exécution de notre programme et la qualité du rendu final. Ce qui va nous intéresser aujourd'hui c'est la rapidité de notre algorithme et en particulier comment on peut déterminer celle-ci approximativement sans en mesurer le temps d'exécution, mais plutôt en analysant l'algorithme lui-même.

À l'origine je souhaitais continuer sur ma lancée en traitant toujours de génération procédurale de terrain. J'envisageais d'y parler très rapidement de complexité algorithmique dans les mots clefs en introduction et je me suis finalement rendu compte que ce n'était pas suffisant pour comprendre l'importance de ce concept et comme le sujet est intéressant, nous nous retrouvons finalement avec un billet à part entière ! Précisons cependant que c'est un vaste domaine et que cet article n'a pas du tout la prétention de tout couvrir, nous n'aborderons ici en fait que ce qui me semble nécessaire pour la suite.

Avant de commencer, définissons deux termes importants utilisés ici:

  • Notion de limite: C'est un concept mathématique qui vise à étudier l'évolution d'une fonction. En particulier, quand la variable de cette dernière tend vers une valeur donnée, on regarde vers quoi la fonction se rapproche à mesure que cette variable tend vers la valeur que l'on se propose. Par exemple on se donne une fonction f(x) = 1/x. La limite de f pour x qui tend vers l'infini s'écrit

lim2.png

  • Asymptote: C'est un terme très fortement lié à la notion de limite. De façon naïve, quand on représente graphiquement une fonction on peut éventuellement s'apercevoir que celle-ci tend vers une valeur ou une forme particulière. Pour mettre en évidence cette tendance, on utilise une droite dite asymptotique telle que l'équation de droite soit approximativement égale à la fonction quand celle-ci tend vers une valeur particulière.

asymptote.png

  • Par exemple ici nous aurions

lim.png

  • En effet, graphiquement nous voyons bien que plus x tend vers l'infini, plus la courbe F semble rejoindre la droite A en sorte de se confondre.

La notion de complexité est très importante quand on s'attarde sur l'optimisation et l'efficacité d'un programme. En particulier, on parle de complexité spatiale et temporelle pour évaluer, respectivement, la quantité de mémoire utilisée et le nombre d'instructions exécutées par un algorithme. Moins un algorithme consomme de mémoire et nécessite d'instructions à exécuter, plus celui-ci est efficace en termes de temps d'exécution et de ressources consommées. L'optimisation d'un programme consiste donc à réduire au mieux la complexité algorithmique. Réduire la complexité temporelle peut impliquer d'augmenter la complexité spatiale et inversement. Selon le contexte, on peut donc préférer plus de rapidité au détriment d'une utilisation massive de la mémoire. Cela peut notamment être le cas pour des programmes dits en temps réel.

L'analyse de la complexité est pertinente en particulier pour des algorithmes de tri, de recherche ou bien, dans notre cas, de calcul intensif, nous ferions donc bien de voir de quoi il en retourne.

Dans la pratique, on ne cherche pas à quantifier avec précision la complexité d'un programme, mais plutôt à déterminer vers quoi celle-ci va tendre. La notion de complexité algorithmique rejoint donc celle des limites en mathématiques. La complexité algorithmique est notée O() et est exprimée par exemple ainsi.

O(n²)

Cette écriture, dit de grand O de n carré (notation de Landau), signifie que, si l'on parle de la complexité temporelle, le temps d’exécution du programme sera proportionnel à n², où n est une variable qui désigne généralement la quantité de données à traiter. Par exemple on se donne le pseudo code suivant qui sert à remplir une matrice:

nouvelle matrix[n][n]

pour y=0 allant jusqu’à n-1, incrémenter y de 1: 
      pour x=0 allant jusqu’à  n-1, incrémenter x de 1:
             matrix[y][x] = x + y * n

Ici la complexité spatiale est de n² puisque nous réquisitionnons n² zone mémoire. Le lecteur attentif que vous êtes pourrait arguer que ce n'est pas tout à fait exact et qu'en fait ce serait plutôt t*n² avec t ayant pour valeur la taille de chaque élément de la matrice. En effet, quel est le type de la donnée ? S'agit-il d'un entier long, d'un octet ou d'un flottant ? En général ce détail n'a pas tant que ça d'importance parce que ce que nous voulons déterminer c'est un ordre de grandeur. Ce qui impacte vraiment sur la complexité spatiale c'est n. On peut donc raisonnablement dire que cette complexité spatiale est en O(n²).

Qu'en est-il de la complexité temporelle? De la même façon, on voit que l'on effectue, trois opérations. Une affectation, une addition et une multiplication. On répète le tout n² fois. On serait tenté de dire que la complexité spatiale est de 3n², mais non, là encore nous dirons que la complexité temporelle est en O(n²) parce que l'on considère que le triplé d'instructions tiens pour une seule opération élémentaire dans notre algorithme. Dit autrement, on peut considérer qu'une opération élémentaire est une action que l'on répète un certain nombre de fois et dont le temps d'exécution est borné.

Pour généraliser, la détermination de la complexité d'un algorithme se fait en excluant les constantes et les termes d'ordres inférieurs. On parle alors de complexité asymptotique. C'est ce que nous avons fait dans notre exemple en utilisant la notation de Landau. Dans un autre algorithme, nous pourrions avoir une complexité plus précise exprimée sous la forme d'une fonction

C(n) = 1n³ +  3n + 7

qui deviendrait alors

O(n³)

Le moins qu'on puisse dire, c'est que c'est approximatif. C'est parce que l'on évalue la complexité dans le pire des cas. C'est à dire quand n est vraiment très grand. En ce qui concerne l'algorithme de Perlin que nous avons présenté dans l'article précédent, on évalue en général sa complexité temporelle en O(2^n). C'est énorme, on dit de ce type de complexité qu'elle est exponentielle. Oui mais voilà. Ici, n ne représente pas le jeu de donnée à traiter, mais le nombre de dimensions dans lequel l'algorithme opère. Dans l'implémentation que je vous proposais, nous travaillions alors en deux dimensions, et pour des raisons dont nous ne parlerons pas pour le moment, il est très probable que nous continuons à travailler dans un espace bidimensionnel. Cette complexité, telle que formulée, ne nous avance donc à rien. Pour évaluer la complexité du Bruit de Perlin dans notre cas d'utilisation il faut se donner une variable, et comme le nombre de dimensions est constant, ce qui nous reste c'est la taille de la heightmap que l'on souhaite générer. Dans l'algorithme du Bruit Perlin, nous avions deux grosses étapes:

  • Le calcul de chacun des produits scalaires en quatre points.
  • L 'interpolation de chacun de ses points.

Ces deux étapes étaient répétées (2^n)² fois. En effet nous travaillons sur une surface plane carré dont la dimension est une puissance de deux (128,256,512, etc), n détermine donc l'échelle de notre surface. Mais ce n'est pas tout. Dans notre algorithme, nous calculons le bruit local pour plusieurs octaves. n octaves pour être exacts. Nous pouvons alors exprimer cette complexité ainsi.

Nous aurions donc une complexité temporelle formulée ainsi

O(n) =  n(2^n)²

Pour généraliser cette expression à un nombre arbitraire de dimensions, noté m, nous aurions alors:

O(n,m) = n(2^n)^m

On peut finalement reprendre les résultats que nous avions obtenus avec notre implémentation du Bruit de Perlin. Je parle évidemment des temps d'exécution affichés:

29 ms pour une surface de 256 pixels²

126 ms pour une surface de 512 pixels²

556 ms pour une surface de 1024 pixels²

2249 ms pour une surface de 2048 pixels²

Pour n = 7 jusqu'à n = 10 le temps d’exécution est multiplié en moyenne par un facteur 4,26. Et je dis bien en moyenne parce que le temps d'exécution, pour un même n varie selon la charge du CPU à un instant donné.

Or on s’aperçoit avec la complexité que l'on s'est donnée précédemment que si on calcule

O(n) / O(n-1)

pour n = 7 jusqu'à n = 10 on a

  • 4,66 pour n=7
  • 4,57 pour n=8
  • 4,50 pour n=9
  • 4,44 pour n =10

C'est sans surprise très proche du temps moyen effectivement mesuré !

Merci à Théo Fish et Benjamin Loubet pour la relecture orthographique!

Génération procédurale de terrain: Part. I, Le Bruit de Perlin amélioré

Avant de se lancer dans la génération procédurale de terrain nous allons rapidement introduire quelques termes importants dont il sera question dans cet article.

  • Heightmap: Ou carte de hauteur. Il s'agit d'une matrice ou d'une image dont chaque indice ou pixel correspond à une élévation locale de la surface qu'on souhaite représenter. On s'en sert donc notamment pour modéliser des terrains.
  • Bruit: Il s'agit d'un signal aléatoire à n dimensions. Typiquement le son de la mer ou la neige du téléviseur. En informatique créer du bruit c'est en fait chercher à produire des valeurs aléatoires qu'on pourra alors utiliser en cryptographie, par exemple, ou dans notre cas, en imagerie de synthèse.
  • Vertex: En infographie 3D, un vertex (vertices au pluriel) est un point dans l'espace repéré par trois coordonnés X,Y et Z. Ce point dans l'espace, quand il y en a plusieurs est ensuite organisé et assemblé avec d'autres vertices en primitives (des triangles ou des segments) qui eux même formerons des objets complexes en 3D.
  • Displacement Mapping: En plaquant une texture de type heightmap sur une surface en 3D, c'est le procédé par lequel on déplace un vertex en fonction de l'intensité du pixel qui lui est associé dans la heightmap.
  • Bumpmap: Il s'agit d'une technique introduite par James Blinn en 1978 permettant de simuler des aspérités sur une surface sans changer les vertices composant celle-ci. C'est donc très pratique lorsque l'on veut ajouter du réalisme tout en faisant l'économie de vertex.
  • Vecteur: Pour faire très simple, si on considère qu'un point est une position dans l'espace, alors un vecteur est un déplacement dans l'espace. Ce qui implique qu'un vecteur possède une direction et une longueur. Ces propriétés peuvent être calculé à partir des coordonnées du vecteur.
  • Gradient: La notion de gradient est très fortement lié à la notion de dérivée et de dérivée partielle. Dans l'un ou l'autre cas on multiplie le résultat par son vecteur unité respectif. On peut donner en exemple les deux expressions ci-dessous, mais il est inutile de trop s'attarder ici sur la notion de gradient parce que dans l'algorithme du Bruit de Perlin les vecteurs gradients sont en fait des vecteurs de norme 1. grad1.png grad2.png
  • Interpolation: Il s'agit d'un procédé qui consiste à créer une transition continue entre deux valeurs discrètes. Mathématiquement ça se traduit par le fait de joindre deux points par une courbe. Pour cela plusieurs formules existent selon le contexte dans lequel on travaille: Interpolation linéaire, cubique, cosinusoïdale, polynomiale, etc... Le choix du type d'interpolation est crucial quand on le met en œuvre dans une application dont la rapidité d'exécution impacte sur le bon fonctionnement du programme. Une interpolation linéaire sera par exemple plus rapide mais moins "jolie" qu'une interpolation polynomiale beaucoup plus efficace mais plus gourmande en temps d'exécution.

Ce qu'on va donc chercher à faire c'est générer une heightmap. La heightmap peut alors être utilisée d'au moins deux façons simultanées si, par exemple, on souhaite faire une planète. Dans le premier cas la heightmap déterminera l'altitude (displacement mapping) de chaque vertex appartenant à la sphère de sorte de faire apparaître des montagnes et des continents. Dans le second cas, souhaitant économiser des ressources par souci d'optimisation, on peut réduire le nombre de vertex et utiliser la heightmap également en tant que Bumpmap. Quoi qu'il en soit il est très probable que la heightmap soit plus élevée en résolution que le maillage auquel on veut l'appliquer, on a donc tout intérêt à combiner les deux techniques.

Pour ce premier article nous allons présenter et détailler le Bruit de Perlin.

Alors, comme on s'en doute, Il s'agit d'une texture procédurale, c'est à dire d'une technique d'imagerie de synthèse. Ce procédé a été inventé par Ken Perlin lorsqu'il travaillait sur le film TRON en 1981. Cet algorithme permet de créer des surfaces d'apparence réaliste et est très utilisé dans le jeu vidéo ou le cinéma. Dans le titre de cet article je parle de Bruit de Perlin amélioré. Ce n'est pas moi qui l'ai amélioré, mais Ken Perlin lui-même peu de temps après la conception de la première version. Dans cet article nous ne parlerons donc que de cette version améliorée sans s'attarder sur les différences.

La fonction Bruit de Perlin, quand elle est utilisée dans un espace en deux dimensions, est une application de R² dans R. C'est à dire qu'elle prend en paramètres deux coordonnées réelles, et retourne une seule valeur réelle qui correspond à la valeur du bruit locale.

Pour ce faire, l'algorithme va fonctionner en deux temps:

Dans la première partie de l'algorithme, on se donne une grille.

perlinGrid.png

Pour chaque point P à coordonnées réelles on considère les nœuds Q à coordonnées entières de la grille qui encadrent ce point P. Par exemple si P = (2.3, 3.7) alors Q1 = (2,3), Q2 = (3,3), Q3 = (3,4) et Q4 = (2,4). La conséquence immédiate c'est que tant que P se situe entre 2 et 3 en x, et y entre 3 et 4, Q1,Q2,Q3 et Q4 ne changeront pas.

On assigne alors à chacun de ces points Q un vecteur gradient pseudo aléatoire. Pour ce faire on dispose du tableau de gradients suivant dans lequel, justement, nous irons piocher pseudo aléatoirement nos vecteurs.

  • 0,1
  • 0,-1
  • 1,0
  • -1,0
  • 1,1
  • 1,-1
  • -1,-1
  • -1,1

Avant d'aller plus loin, il faut expliquer de quelle manière on récupère nos indices aléatoires. Dans l'algorithme de Perlin, On ne fait à aucun moment appel à la fonction rand(). En fait, on dispose d'un tableau de 256 entrées (certaines implémentations en comportent le double). Ce tableau contient en fait tous les entiers de 0 à 255 dans le désordre. Ce qui signifie que soit ce tableau est fourni comme tel, inscrit en dur dans le programme, soit celui-ci est généré AVANT de lancer l'algorithme. Ce tableau dont on parle est ce qu'on appelle une table de permutations.

La formule pour déterminer l'indice du gradient dans le tableau est donc de cette forme là quand l'algorithme est utilisé dans un espace en deux dimensions:

Gn = P[ ( Qnx + P[ Qny & 255 ] ) & 255 ] & 7
  • Avec Gn l'indice du gradient dans le tableau de vecteurs, associé au point à coordonnées entière Qn.
  • Avec Qnx et Qny, respectivement, les coordonnées entières en x et y de Qn.
  • Avec P qui, ici, représente notre table de permutation à 256 valeurs.

L'usage des masques de bit sert à éviter d'utiliser des opérations modulo gourmandes en temps CPU quand les valeurs Qnx et Qny sont supérieures à 255.

Nous en parlerons plus bas, mais en l'état cette formule n'est pertinente que si l'on utilise notre Bruit de Perlin pour une octave donnée. Elle ne convient donc pas si l'on souhaite faire, par exemple, un terrain procédural. Ou plutôt, ça fonctionnera, mais le Bruit produira des défauts sous la forme de motifs qui se répéteront et qu'un œil pourtant distrait remarquera sans aucun doute. Nous y reviendrons.

À l'aide de la formule donnée nous avons donc quatre vecteurs gradients associés respectivement à chaque points Q.

perlinGrid2.png

L'algorithme nous dit maintenant qu'il faut calculer, pour chaque point Qn, le produit scalaire Gn · (P-Qn). Notons au cas où ce ne serait pas clair qu'ici P ne fait pas référence à la table de permutations mais au point P dont les coordonnées ont été données en paramètre à notre fonction Bruit de Perlin. Rappelons également que le produit scalaire, quand il est effectué dans une base orthonormale est simplifié. C'est le cas ici. On a donc:

Gnx * (x-Qnx) + Gny * (y-Qny);

Avec respectivement, Gnx et Gny correspondant à la coordonnée x et y du gradient Gn

Nous arrivons maintenant à la seconde partie de l'algorithme. Une fois que nous avons calculé nos produits scalaires en chaque point Qn il faut maintenant interpoler ceux-là pour en extraire la valeur au point P. Valeur qui sera, vous l'avez deviné, celle qui sera retournée par notre fonction Bruit de Perlin.

Notons s,t,u et v respectivement le résultat des produits scalaire en Q1,Q2,Q3 et Q4. Nous avons alors à calculer trois interpolations.

  • La première, horizontale, entre s et t dont on notera le résultat iST
  • La seconde, horizontale, entre u et v dont on notera le résultat iUV
  • La troisième, verticale, entre iST et iUV qui sera le résultat retourné par la fonction de bruit.

perlinGrid3.png

Pour interpoler, et bien il nous faut une formule. Nous pourrions utiliser une interpolation linéaire mais le rendu final en souffrirait. On ne détaillera pas ici les raisons exactes de ce choix, mais Ken Perlin propose la formule suivante:

6t⁵- 15t⁴ + 10t³

La formule ci-dessus à la propriété intéressante, si l'on travaille dans l'intervalle {0,1}, de s'annuler en ses extremums, et de le faire également en sa dérivée et dérivée seconde.

Et nous allons justement travailler dans cet intervalle. Rappelons que la coordonnée x du point P varie entre Q1x et Q1x + 1, avec Q1 le point à coordonnée entière qui correspond au sommet en haut à gauche de notre carré qui encadre notre point P. Autrement dit le nombre x - Q1x appartient à l'intervalle {0,1}. Et là, normalement, vous me voyez venir. Ce qu'on va faire, c'est qu'on va injecter dans notre formule d'interpolation la valeur x-Q1x de sorte à récupérer un coefficient dont on se servira pour calculer la valeur interpolée en un point donné. Par exemple, pour calculer l'interpolation horizontale des valeurs s et t, respectivement au point Q1 et Q2 nous ferions:

iST = s +interpolation( x -Q1x ) * (t - s)

Il n'y à plus qu'a appliquer le calcul pour déterminer iUV et recommencer une dernière fois pour interpoler iST et iUV. Et maintenant... Et bien... C'est fini, du moins en théorie.

Nous avons évoqué tout à l'heure, sans plus de détail, le fait de générer du bruit pour une octave donnée. Cette notion est très importante. Dans notre Bruit de Perlin, nous avons mentionné au début de notre article une grille. Mais cette grille est une grille unitaire, pour avoir du sens dans une application concrète il faut que cette grille soit mise à l'échelle. Et c'est précisément l'octave qui détermine l'échelle de notre grille. Selon l'octave que l'on se donne le rendu final du bruit sera plus 'fin' ou 'épais'. Rien ne vaut une image pour illustrer mon propos. Ci-dessous, pour une image de 512x512 dans laquelle on fait varier l'octave d'une puissance de deux à la suivante jusqu'à 512, en partant de deux, pas de un (sinon l'image est complètement noire, l'algorithme ne fonctionne pas). Plus l'octave est grande plus le bruit est épais.

perlinNoiseOctaves.png

Bon mais alors attention, si vous avez suivi, vous vous souvenez sans doute que j'ai dit que la formule permettant de sélectionner pseudo aléatoirement un gradient ne fonctionnait que pour une octave donnée. En effet, lorsque l'on veut générer une heightmap, on additionne pour une coordonnée donnée chaque bruit local à cette coordonnée pour chaque octave que l'on se donne. Mais voilà, on a bien dit que nos gradients étaient choisis pseudo aléatoirement à l'aide d'une formule qui est fonction des coordonnées qu'on lui donne.

Si on s'en tient à cette formule plus haut, on va se retrouver dans la situation fort déplaisante où ce que fera en réalité l'algorithme, pour chaque octave, c'est produire le MÊME bruit à une échelle différente. Et ce n'est pas bon du tout.

perlinHomotetie.png

Nous, ce que nous voulons, c'est produire du bruit différent à chaque octave.

La formule

Gn = P[ ( Qnx + P[ Qny & 255 ] ) & 255 ] & 7

devient donc

Gn = P[ ( Qnx + P[ Qny & 255 ] + octave ) & 255 ] & 7

Pour parachever notre algorithme, il faut assigner à chaque bruit local, en fonction de l'octave, un coefficient. On part de l'octave la plus grande avec un coefficient fixé à un. Et pour chaque itération, on divise par deux l'octave et on divise par deux notre coefficient.

Bon alors c'est super, mais visuellement qu'est-ce que ça donne?

proceduralHeightmap0x0B.png

Et quand on passe ça dans GIMP pour faire ressortir les aspérités avec de la lumière?

proceduralHeightmap0x0C.png

C'est du plus bel effet!

Pour terminer, je vous propose un exemple d'implémentation de mon cru relativement optimisée et abondamment commentée dont vous pouvez trouver les sources complètes ici:

https://github.com/DenisSalem/UNIVERSE/tree/master/PoC/improvedPerlinNoise

#include <stdio.h>
#include <stdlib.h>
#include "png.c"

// On définit une macro d'interpolation, à priori plus rapide qu'un appel de fonction.
// la formule est la suivante:  6t^5 - 15t^4 + 10t^3
#define interpolation(t) (6 * t * t * t * t * t - 15 * t * t * t * t + 10 * t * t * t)

//  Certaines variables et tableaux sont globaaux afin de ne pas être empilés lors de
//  l'appel de la fonction PerlinNoise2D, ce qui nous fera économiser des instructions
//  et du temps CPU.

// G est le tableau de gradient.
// Ici G comporte 8 paires de coordonnées. Dans l'implémentation améliorée G comporte
// 12 triplets de coordonnées pour pouvoir travailler dans un cube, et comme ce n'est pas
// le cas ici... :)
int G[8][2] = {
    {1,1},    {-1,1},   {1,-1},   {-1,-1},
    {1,0},    {-1,0},   {0,1},    {0,-1},
};

// Chaque vecteur gradient est référé par un indice Gn du tableau G
int G1,G2,G3,G4;

// Contient le résultat du produit scalaire de Gn avec P-Q dans une base orthonormale.
double s,t,u,v;

//Contient les coordonnées X et Y des points Qn et du point P.
int Q1[2] ,Q2[2],Q3[2],Q4[2];
double p[2];

// Résultat de la macro d'interpolation en X et Y, tmp permet de stocker P-Q avant d'être
// injecté dans la macro.
double iX,iY,tmp;

// Résultat de l'interpolation horizontal en st et uv
double iST;
double iUV;

// La table de permutations. En l'état, l'algorithme produira toujours le même terrain 
// pseudo-aléatoire. Pour obtenir un vrai terrain pseudo-aléatoire différent à chaque
// lancement du programme il faudrait changer ou désordonner cette table de permutations
// avant d'entrer dans la boucle principale ou est appelée notre fonction de bruit.
int P[256] = {
 235,249,14,239,107,49,192,214, 31,181,199,106,157,184, 84,204,176,115,121,50,45,
 127, 4,150,254,138,236,205,93,222,114,67,29,24,72,243,141,128,195,78,66,215,61,
 112,104,218,246,97,228,251,34,242,193,238,210,144,12,191,179,162,241,81,51,145,
 153,101,155,167, 43,172,9,129,22,39,253, 19,98,108,110,79,113,224,232,178,185,
 74,165,71,134,139,48,27,166,77,146,158,231,83,111,229,122,60,211,133,230,220,
 187,208, 89,18,169,200,196,135,130,116,188,159,86,164,100,109,198,173,186,3,
 47,16,58,17,182,189,28,42,223,183,170,213,119,248,152, 2,44,154,163, 70,221,
 64,52,217,226,250,124,123,5,202,38,147,118,126,255,82,85,212,207,206,59,227,
 203,117,35,11,32,57,177,33,88,237,149,56,87,174,20,125,136,171,168, 68,175,
 105,92,41,55,46,245,40,244,102,143,54, 65,25,63,161,1,216,80,73,209,76,132,
 142,8,99,37,240,21,10,23,190, 6,148,247,120,234,75,0,26,197,62,94,252,219,
 151,160,137,91,90,15,131,13,201,95,96,53,194,233,7,225,140,36,103,30,69,
 156,180};


// Les coordonnées x et y doivent être des nombres réels positifs, sinon nous serions en 
// dehors de notre grille. Pour cela on envoie à notre fonction les coordonnées d'un
// point de la matrice, que l'on remet ensuite à l'échelle de la façon suivante
// C = c / scale
// avec C la coordonnée mise à l'échelle et c la coordonné entière d'origine.

// La fonction prend également en paramétre un entier -l'octave-
// positif qui détermine la taille de notre grille. Par exemple si la matrice
// de destination à une résolution de 256² éléments et que notre entier vaut
// 128 alors alors la taille de la grille sera en fait de 2² éléments, soit 3²
// noeuds :
//
//
//    <-- 256 --> taille réelle de la matrix
//
//    0----1----2
//    |    |    |
//    3----1----5   Here stands the Grid. A digital frontiere...
//    |    |    |
//    6----7----8
//
//    <--- 2 ---> taille de la grille mise à l'échelle
    
double PerlinNoise2D(int x, int y, unsigned int scale) {

  // L'initialisation peut sembler alambiquée, mais c'est 
  // pour épargner au processeur des calculs inutiles.

  p[0] = (double) x / scale;
  p[1] = (double) y / scale;

  // Coin supérieur gauche
  Q1[0] = (int) p[0];
  Q1[1] = (int) p[1];
  
  // Coin supérieur droit
  Q2[0] = Q1[0] + 1;
  Q2[1] = Q1[1];

  // Coin inférieur droit
  Q3[0] = Q2[0];
  Q3[1] = Q1[1] + 1;

  // Coin inférieur gauche
  Q4[0] = Q1[0];
  Q4[1] = Q3[1];


  // On récupére pseudo aléatoirement les gradients.
  // Pour éviter la répétition de motifs fractals à chaque octave 
  // l'indice final dépend de l'octave courant
  G1 = P[ (Q1[0] + P[ Q1[1] & 255] + scale) & 255 ] & 7;  // Gradient supérieur gauche
  G2 = P[ (Q2[0] + P[ Q2[1] & 255] + scale) & 255 ] & 7;  // Gradient supérieur droit
  G3 = P[ (Q3[0] + P[ Q3[1] & 255] + scale) & 255 ] & 7;  // Gradient inférieur droit
  G4 = P[ (Q4[0] + P[ Q4[1] & 255] + scale) & 255 ] & 7;  // Gradient inférieur gauche

  // On calcule le produit scalaire Gn . (P-Qn)
  // Avec P faisant référence aux coordonnées du point stocké dans p.
  // (P étant la table de permutations)
  s = G[G1][0] * (p[0]-Q1[0]) + G[G1][1] * (p[1]-Q1[1]);
  t = G[G2][0] * (p[0]-Q2[0]) + G[G2][1] * (p[1]-Q2[1]);
  u = G[G3][0] * (p[0]-Q3[0]) + G[G3][1] * (p[1]-Q3[1]);
  v = G[G4][0] * (p[0]-Q4[0]) + G[G4][1] * (p[1]-Q4[1]);

  
  // On interpole en x sur le segment st et uv
  tmp = p[0]-Q1[0];
  iX = interpolation(tmp);

  iST = s + iX * (t-s);
  iUV = v + iX * (u-v);

  // On interpole y sur le segment iST et iUV
  tmp = p[1]-Q1[1];

  iY = interpolation(tmp);

  // On retourne le résultat normalisé.
  return (1 + iST + iY * (iUV - iST)) * 0.5;
}

int main(int argc, char ** argv) {
  if (argc == 1) {
    printf("usage: ./improvedPerlinNoise <power of two>\n");
    return 0;
  }
  int scale = atoi(argv[1]);


  // Matrice de 256 pixels² simulé avec un tableau de longueur 256²
  // C'est dans ce tableau que nous allons stocker notre heightmap
  double * grid = (double *) malloc(sizeof(double) * scale * scale);
  
  // i et j correspondent respectivement aux axes x et y.
  // k correspond à l'octave courrante.
  int i,j,k;

  float min=2,max=0;

  // Selon le type de texture on peut ne pas utiliser de coef ou
  // l'utiliser différemment. Mais l'idée ici est de diminuer
  // l'influence du bruit à mesure que la fréquence augmente.
  double coef = 1.0; 

  for(j=0; j<scale;j++) {
    for(i=0; i<scale;i++) {
      coef = 1.0;
      grid[ i + j*scale] = 0;
      for(k=scale/2;k>=1; k/=2) {
        grid[ i + j*scale ] += PerlinNoise2D(i,j, k) * coef;
        coef /= 2.0;
      }
      if (min > grid[ i + j*scale]) {
        min = grid[ i + j*scale];
      }
      if (max < grid[ i + j*scale]) {
        max = grid[ i + j*scale];
      }
    }
  }

  // Ici la texture est terminée. Il ne reste plus qu'à la normaliser en
  // vue de l'exploiter.

  double normalizedIntensity;
  PIXEL ** matrix = (PIXEL **) malloc( scale * sizeof(PIXEL * ));
  for(j=0;j<scale;j++) {
    matrix[j] = (PIXEL *) malloc( scale * sizeof(PIXEL));
    for (i=0; i< scale;i++) {
      normalizedIntensity = (grid[ i + j * scale ] - min) * ( 1.0 / (max-min) );
      matrix[j][i].Alpha  = 255;
      matrix[j][i].Red    = (char) (normalizedIntensity * 255);
      matrix[j][i].Blue   = (char) (normalizedIntensity * 255);
      matrix[j][i].Green  = (char) (normalizedIntensity * 255);
    }
  }

  writePng(matrix, scale);
  return 0;
}

Avec cette implémentation, sur un core i3, et en ne gardant que la partie qui génère le bruit, on s'en tire en moyenne avec ces temps d’exécution:

  • 29 ms pour une surface de 256 pixels²
  • 126 ms pour une surface de 512 pixels²
  • 556 ms pour une surface de 1024 pixels²
  • 2249 ms pour une surface de 2048 pixels²

Est-ce qu'on peut mieux faire? Y a-t-il d'autres algorithmes?

La réponse dans le prochain article!

Merci à Benjamin Loubet et Théo Fish pour la relecture orthographique!