Namide

Bump mapping avec Pixel Bender

Il y a trois ans je me suis lancé dans un algorithme en ActionScript 3 permettant un rendu en bump mapping (fondé sur la méthode de Blinn) à l'aide de BitmapData. Le moteur de Flash est assez puissant pour ce genre de rendu temps réel mais à grande échelle, le processeur aura probablement du mal à gérer les calculs. Qu'en est-il avec Pixel Bender ?

Je ne vais pas développer toutes les étapes dans cet article mais seulement celle qui m'intéresse : calculer l'effet de texture ombrée depuis une normal map.

Height map
Height map générée à partir d'un bruit de Perlin et d'une demi-sphère

Tout d'abord un petit récapitulatif : le bump mapping consiste à simuler des effets de relief sur une texture. Pour cela, on génère des ombres produites par le relief de la texture. Ensuite nous passons par quelques étapes que sont la height map (ou bump map) et la normal map (calculée à partir de la height map). Attention, nous ne parlons pas d'ombre portée mais d'ombre produite par l'inclinaison de la surface : plus une surface est face à la lumière, plus elle sera éclairée.

La height map est une image représentant le relief d'une texture. Les zones les plus claires se rapprochent de nous tandis que les sombres s'éloignent. Les height map sont en gamme de gris. La variation de la hauteur est comprise dans chaque canal (c'est la même valeur pour les canaux rouge, vert et bleu). Si l'on crée une height map en utilisant la plage complète de la couleur (valeur de 24 bits) au lieu de mettre la même valeur dans les 3 canaux (valeur de 8 bits), on multiplie grandement la précision. C'est ce que j'ai fait pour l'exemple de cet article. Mais du coup la height map ne ressemble plus à rien. ^^

La normal map est obtenue à partir de la height map. Un script Pixel Bender anciennement disponible sur le site Adobe nous permettait d'obtenir ce calcul sans transpirer. Vous pouvez le télécharger ici ^^ . Pour les besoins de cet article, je n'ai pas utilisé ce script ; j'en ai redéveloppé un en ActionScript 3.

Pour obtenir un point d'une normal map {x, y}, je pars de 3 points de la height map A {x, y}, B {x+1, y} et C {x, y+1}. À partir de ces 3 points, nous pouvons obtenir un vecteur dont la valeur représente la normale de l'inclinaison du relief. En imaginant que la valeur de couleur de la height map en l'emplacement {x, y} représente la position z nous avons 3 points (A, B et C) de 3 dimensions. A sert de référence, B permet de calculer le décalage de Z par rapport à A (sachant qu'il est éloigné d'un pixel en abscisse) et C le décalage de Z par rapport à A (éloigné d'un pixel en ordonnée). Nous pouvons ainsi calculer la normale de la pente à partir de ces "décalages".

Normal map
Normal map générée à partir de la height map.

Ci-dessous, une partie du calcul pour un point de notre height map. Si vous voulez l'implémenter il faudra renseigner la variable _heightMap et effectuer une boucle pour calculer tous les points de la texture (ici, nous ne calculons que le résultat concernant le point {i, j}).

var _heightMap:BitmapData;		// La height map
var _normalMap:BitmapData;		// La normal map (générée après le calcul)
var _altitude:Number = 200;		// la valeur de hauteur pour un point blanc sur la height map

/*

...

*/

var i:int, j:int;

// Point en x = i et y = j
var ptA:Vector3D = new Vector3D(	i,	 j,		_heightMap.getPixel(i,   j)	* _altitude / 0xFFFFFF	);
// Point en x = i+1 et y = j
var ptB:Vector3D = new Vector3D(	i+1, j,		_heightMap.getPixel(i+1, j)	* _altitude / 0xFFFFFF	);
// Point en x = i et y =  j+1
var ptC:Vector3D = new Vector3D(	i,	 j+1,	_heightMap.getPixel(i, j+1)	* _altitude / 0xFFFFFF	);

// Normale calculée à partir des 3 points
var norm:Vector3D = calcNorm( ptA , ptB , ptC );

// Canaux Rouge, Vert et Bleu
var r:int = int(norm.x * 0x7F + 0x7F);
var v:int = int(norm.y * 0x7F + 0x7F);
var b:int = int(norm.z * 0x7F + 0x7F);

var color:uint = r << 16 | v << 8 | b;
_normalMap.setPixel(i, j, color);

// Normale à partir de 3 points
function calcNorm( ptsA:Vector3D , ptsB:Vector3D , ptsC:Vector3D )
{
var AB:Vector3D, AC:Vector3D;
var norm:Vector3D = new Vector3D();

AB = ptsB.subtract( ptsA );
AC = ptsC.subtract( ptsA );

norm.y = AB.y * AC.z - AB.z * AC.y;
norm.x = AB.z * AC.x - AB.x * AC.z;
norm.z = AB.x * AC.y - AB.y * AC.x;

norm.y *= -1;

norm.normalize();
return norm;
}
Pour visionner le rendu final vous devez posseder la versions 10.0 de FlashPlayer.
Télécharger la dernière version de Flashplayer
Bump mapping avec une texture générée par un bruit de Perlin. Passer le curseur sur l'animation pour changer la position de la lumière.

Les valeurs x, y et z du vecteur de cette normale sont respectivement stockées dans les canaux rouge, vert et bleu du point sur la normal map. C'est ainsi que nous obtenons la normal map : une image gardant les informations de pente de notre texture.

Ensuite, il est plus facile de calculer des ombres à partir de cette normal map. Notre lumière est représentée par un vecteur. Si ce vecteur est face au vecteur de notre texture : c'est éclairé, s'il est de dos : c'est sombre. Pour calculer ce taux de luminosité par point je me contente de soustraire les deux vecteurs entre eux et de récupérer la longueur du vecteur obtenu. Vous l'aurez compris cette partie est calculée à chaque mouvement de la texture ou de la lumière ; c'est cette partie qui doit être calculée rapidement. C'est pour cette raison que nous utiliserons Pixel Bender.

Une fois le taux de luminosité obtenu, il faut l'appliquer à la texture originale. Pour simplifier l'explication, nous allons appeler cette image contenant les taux de luminosité une shadow map, bien que celle-ci ne soit pas réellement représentée. Nous allons maintenant multiplier ou diviser la texture avec la shadow map selon la luminosité. Cela permettra d'assombrir ou d'illuminer notre texture en fonction de la shadow map.

Ci-dessous, le script Pixel Bender qui part d'une normal map, d'une texture et d'une lumière pour retourner la texture avec les ombres appliquées.

<languageVersion : 1.0;>

kernel bumpMap
<   namespace : "Namide";
	vendor : "Damien Doussaud";
	version : 2;
	description : "Bump Mapping"; >
{

	input image4 normalMap;
	input image4 skinMap;
	output pixel4 dst;

	// direction de la source de lumière
	parameter float3 light
	<
		minValue:float3( -1.0, -1.0, -1.0 );
		maxValue:float3( 1.0, 1.0, 1.0 );
		defaultValue:float3( 0.0, 1.0, 0.33 );
	>;

	// intensitée du contraste
	parameter float contrast
	<
		minValue:float( 0.0 );
		maxValue:float( 10.0 );
		defaultValue:float( 1 );
	>;

	// effet de cellShading
	parameter bool cellShading
	<
		defaultValue:false;
	>;

	void
	evaluatePixel()
	{

		pixel4 normalMapIn = sampleNearest(normalMap, outCoord());

		// séparation des canaux de la normal map pour obtenir un Vector3D
		float r = float( normalMapIn.r );
		float g = float( normalMapIn.g );
		float b = float( normalMapIn.b );
		float3 texturVect = float3( r, g, b );

		// on passe les valeurs du Vector3D de 0 < 1 à -1 < 1
		texturVect *= 2.0;
		texturVect -= 1.0;

		// soustraction pour connaître l'intensité
		float3 newVect = texturVect - light;
		float eclairage = length( newVect ) / 2.0; // 0 -> 1


		// récupération de la texture
		pixel4 skinIn = sampleNearest(skinMap, outCoord());


		// calcul de la couleur de sortie, selon l'intensité lumineuse
		pixel4 colorOut;

		eclairage -= 1.0;
		eclairage *= contrast;


		if(cellShading)
		{
			if(eclairage> 0.25)         eclairage = 0.75;
			else if(eclairage<-0.25)    eclairage = -0.75;
			else                        eclairage = 0.0;
		}

		if( eclairage > 0.0 )
		{
			eclairage = 1.0 - eclairage;
			colorOut = pixel4( skinIn.r / eclairage,  skinIn.g / eclairage,  skinIn.b / eclairage, 1);
		}
		else
		{
			eclairage = 1.0 + eclairage;
			colorOut = pixel4( skinIn.r * eclairage,  skinIn.g * eclairage,  skinIn.b * eclairage, 1);
		}

		dst = colorOut;



	}
}