FlashPascal 3D


Cet article sur vous propose une explication de source sur un exemple de FlashPascal2 qui dessine des cubes en 3D.



Flash et la 3D

En premier lieu, il est nécessaire de préciser que FlashPascal utilisant une machine virtual Flash version 2 (correspondant à Flash 8), la 3D n'est pas supportée nativement. Le moteur de rendu graphique ne permet de produire que des effets 2D.

Mais qu'à cela ne tienne, avec un peu de mathématiques, nous ferons une projection de la 3D vers la 2D gérée par Flash.

Un peu de mathématiques

Dans notre exemple, nous nous contenterons de faire tourner un cube sur lui même, il est donc question de Rotation vectorielle. Mais bon, le but n'étant pas ici de faire un cours magistral de mathématiques, nous irons au plus simple.

Pour exprimer une rotation, nous utilisons la notion d'Angle. Exprimé en degrés, un tour complet représente 360°, un demi tour 180°, et quart de tour 90° (l'angle droit), etc. Mais en informatique on utilise généralement un autre unité, le Radian, qui n'est pas plus compliqué que le degré, il utilise simplement une autre valeur de référence: π (Pi), soit environ 3,141592654. Un tour complet c'est 2*PI, un demi tour PI, un quart de tour PI/2. D'où la formule Radian = Degre * PI/180.

Pourquoi Pi ? c'est une bonne question, mais je passerais à côté, sachez juste que c'est un nombre magique qui a la particularité de ne pouvoir s'écrire autrement que π. En effet toute autre notation comme 3.14592654, ou 22/7 ne sont que des approximations car π a un nombre infinie de chiffres après la décimale.

Bref, j'ai donc une rotation exprimée en radian (ou en degré peu importe), comment cela va-il influencer mon cube ? Pour le savoir, il faut connaître deux fonctions mathématiques complémentaires, le Sinus et le Cosinus.

J'ai résumé visuellement ces deux fonctions dans l'animation ci-dessous


Le trait blanc tourne autour du centre du cercle vert. Les lignes rouges représentent le sinus et le cosinus de l'ange. Ce sont donc deux valeurs qui évoluent de -1 à +1 (on considère que le cercle vert représente "1") en boucle. En effet Sin(0) = Sin(2*PI). La courbe jaune en bas représente l'évolution du sinus de l'angle au cours du temps sur la durée de deux rotations. La courbe violette c'est le cosinus. Vous remarquerez une certaine similitude entre les deux, c'est lié aux deux symétries du cercle, tous les quarts de cercle on peut inverser le sinus et le cosinus en changeant de signe au besoin.

Et voilà, c'est tout ! C'est tout ? Oui, oui j'en ai fini avec les maths compliquées :)

Mais concrètement à quoi ça sert ? Pour dessiner la ligne blanche qui tourne, j'ai deux fonctions FlashPascal moveTo et lineTo, je dois juste connaître les deux extrémités du trait pour le dessiner. Et justement, je connais le centre qui ne bouge pas, mais qu'elles sont les coordonnées du bout qui bouge ?

Prenons les cas limites, au départ le trait est horizontal, disons 200 pixels: moveTo(0,0); lineTo(0,200). Au bout d'un quart de tour c'est facile aussi moveTo(0, 0); lineTo(200, 0) ! Ok, mais entre le deux, la diagonale à 45° (ou Pi/4), comment je la trace ?

C'est là qu'interviennent nos amis sinus et cosinus; si vous regardez les traits rouges, c'est eux qui me donne la position du point !
  A = 45 * Math.PI/180; // 45° exprimé en radians
  cs := 200 * cos(A); // le cosinus pour un trait de 200 pixels
  sn := 200 * sin(A); // le sinus pour un trait de 200 pixels
  moveTo(0, 0); // le point de départ
  lineTo(cs, sn); // le point d'arrivé

Simple non ? Et ça marche évidemment dans tous les cas, si l'angle vaux 0, sinus = 0, cosinus = 1, on retrouve bien notre lineTo(0, 200); si l'angle vaux 90°, sinus = 1, cosinus = 0, on retrouve encore notre lineTo(200, 0).

Rotation en 3D

Bon ok c'est bien joli, mais la rotation en 3D ça doit être plus compliqué ? En fait non, en 3 dimensions la rotation s'effectue autour d'un axe qui n'affecte que deux des trois coordonnées.



Regardez bien tourner ce cube, vous verrez que la hauteur des points ne changes pas (en 3 dimensions, nous allons voir après la projection). Les formules ne sont donc pas plus compliquées, elles s'appliquent simplement différemment en fonction de l'axe de rotation.
// rotation sur l'axe des X
  y1 :=  cos(a) * y - sin(a) * z;
  z1 :=  sin(a) * y + cos(a) * z;
// rotation sur l'axe des y
  x1 :=  cos(a) * x + sin(a) * z;
  z1 := -sin(a) * x + cos(a) * z;
// rotation sur l'axe des z
  x1 :=  cos(a) * x - sin(a) * y;
  y1 :=  sin(a) * x + cos(a) * y;

Pour avoir une rotation plus complexe, il suffira de combiner ces rotations de bases

Le cube ci-dessus subit une rotation de PI/4 en X, puis Y et enfin Z.

Le calcul matriciel

Nous savons maintenant comment faire subir tout un tas de transformations à un point 3D. Il faudra faire ces calculs sur l'ensemble des points de l'univers, un cube en ce qui nous concerne. Mais cette opération peut prendre du temps, on va donc utiliser un autre outil mathématique précieux pour nous faciliter la vie: la matrice.

Au départ, les matrices ne sont qu'une notation particulière d'une équation. Nous avons vu que pour calculer une rotation selon un axe donné il faut multiplier x, y et z par certains facteurs. La notation matricielle de ces équations harmonise les choses; quand une variable n'est pas impliquée dans l'équation son facteur est nul.
type
  TMatrice: array[0..2, 0..2] of Single;
var
  Matrice: TMatrice;
begin
// Matrice de rotation selon l'axe des X
  Matrice[0, 0] :=  1; 
  Matrice[0, 1] :=  0;
  Matrice[0, 2] :=  0;

  Matrice[1, 0] :=  0; 
  Matrice[1, 1] :=  cos(a);
  Matrice[1, 2] := -sin(a);

  Matrice[2, 0] :=  0; 
  Matrice[2, 1] :=  sin(a);
  Matrice[2, 2] :=  cos(a);

  x1 := Matrice[0, 0] * x + Matrice[0, 1] * y + Matrice[0, 2] * z;
  y1 := Matrice[1, 0] * x + Matrice[1, 1] * y + Matrice[1, 2] * z;
  z1 := Matrice[2, 0] * x + Matrice[2, 1] * y + Matrice[2, 2] * z;
end;

en plaçant les facteurs dans une matrice, on peut appliquer bêtement et méchamment une transformation sur un point sans se préoccuper de la nature de cette transformation. Vous noterez qu'en utilisant (1,0,0) sur la première ligne, on obtient x1 = x ce qui est tout à fait normale pour une rotation sur X.

On peut ajouter une colonne à notre matrice pour gérer la translation :
type
  TMatrice: array[0..2, 0..3] of Single;
var
  Matrice: TMatrice;
begin
  x1 := Matrice[0, 0] * x + Matrice[0, 1] * y + Matrice[0, 2] * z + Matrice[0, 3];
  y1 := Matrice[1, 0] * x + Matrice[1, 1] * y + Matrice[1, 2] * z + Matrice[1, 3];
  z1 := Matrice[2, 0] * x + Matrice[2, 1] * y + Matrice[2, 2] * z + Matrice[2, 3];
end;

Mais là ou les matrices deviennent intéressantes, c'est que l'on va pouvoir combiner les matrices. Au lieu de calculer une rotation selon X de tous les points de notre univers, puis de calculer une rotation selon Y de ces mêmes points, nous allons combiner les deux matrices de rotation dans une nouvelle matrice qui effectuera les deux rotations d'un seul coup sur chaque point.

voici comment on combine deux matrices M1 et M2 dans la matrice M:
  for i := 0 to 3 do
    for j := 0 to 3 do
      M[i, j] := M1[i,0] * M2[0,j]
               + M1[i,1] * M2[1,j]
               + M1[i,2] * M2[2,j]
               + M1[i,3] * M2[3,j];
Vous noterez que pour les besoins du calcul, la matrice doit être carrée, on lui ajoutera donc une ligne.

Pour calculer une rotation selon X puis une rotation selon Y on écrira donc:
type
  TMatrice: array[0..3, 0..3] of Single;
var
  M1, M2, M :TMatrice;
  i, j: Integer;
begin
// Matrice de rotation selon l'axe des X
  M1[0, 0] :=  1; 
  M1[0, 1] :=  0;
  M1[0, 2] :=  0;
  M1[0, 3] :=  0;

  M1[1, 0] :=  0; 
  M1[1, 1] :=  cos(a);
  M1[1, 2] := -sin(a);
  M1[1, 3] :=  0;

  M1[2, 0] :=  0; 
  M1[2, 1] :=  sin(a);
  M1[2, 2] :=  cos(a);
  M1[2, 3] :=  0;

  M1[3, 0] :=  0; 
  M1[3, 1] :=  0;
  M1[3, 2] :=  0;
  M1[3, 3] :=  1;

// Matrice de rotation selon l'axe des Y
  M1[0, 0] :=  cos(b); 
  M1[0, 1] :=  0;
  M1[0, 2] :=  sin(b);
  M1[0, 3] :=  0;

  M1[1, 0] :=  0; 
  M1[1, 1] :=  1;
  M1[1, 2] :=  0;
  M1[1, 3] :=  0;

  M1[2, 0] := -sin(b); 
  M1[2, 1] :=  0;
  M1[2, 2] :=  cos(b);
  M1[2, 3] :=  0;

  M1[3, 0] :=  0; 
  M1[3, 1] :=  0;
  M1[3, 2] :=  0;
  M1[3, 3] :=  1;

// On combine les deux matrices
  for i := 0 to 3 do
    for j := 0 to 3 do
      M[i, j] := M1[i,0] * M2[0,j]
               + M1[i,1] * M2[1,j]
               + M1[i,2] * M2[2,j]
               + M1[i,3] * M2[3,j];

// Appliquer la matrice M revient à appliquer successivement les matrices M1 et M2
  x1 := M[0, 0] * x + M[0, 1] * y + M[0, 2] * z + M[0, 3];
  y1 := M[1, 0] * x + M[1, 1] * y + M[1, 2] * z + M[1, 3];
  z1 := M[2, 0] * x + M[2, 1] * y + M[2, 2] * z + M[2, 3];
end;

Et on peut évidemment combiner autant de matrice que l'on veux avant d'appliquer la transformation finale aux points de notre univers. Les matrices apportent donc un énorme gain en terme de temps de calcul.

J'en ai presque terminé avec les matrices, il me faut vous préciser que la matrice nulle ou "identique" est la matrice qui ne fait aucune transformation : x1 = x, y1 = y et z1 = z, elle se notera naturellement (quand on n'oublie pas qu'elle contient des facteurs de x, y et z) :
1, 0, 0, 0
0, 1, 0, 0
0, 0, 1, 0
0, 0, 0, 1



Passage de la 3D à la 2D

On sait maintenant comment faire tourner un point dans l'espace, mais il nous reste une étape à franchir dans cette exploration de la 3D : la projection.

Même les derniers écrans 3D sont en réalités des images 2D, une pour chaque oeil, c'est notre cerveau qui va leur donner du volume. On va donc devoir appliquer sur la surface plane de notre écran un dessin 2D représentant notre espace en 3 dimensions.


Il existe au moins deux façon de représenter la 3D, le cube vert de gauche est en représentation isométrique; c'est celle qu'on trouvera sur les plans de maquettes, de Lego ou de meubles suédois. Elle est physiquement fausse mais présente l'avantage d'être simple à réaliser.

Celle qui nous intéresse est la seconde, c'est une représentation réaliste de la 3D avec un point de fuite. Plus un objet est loin, plus il est petit. Le cube de droite est donc "déformé" par l'effet de profondeur...tout comme le sont les objets du monde réel.

Cet effet de profondeur est en fait très simple à obtenir, il suffit de diviser les coordonnées 3D x et y par la profondeur z. On prendre soins de placer les objets de telle sorte qu'on ne calcule jamais une division par 0, et on appliquera un facteur de grossissement qui correspond à l'angle de vue (la focale).

Mise en pratique

Si on en revient à notre démo FlashPascal de Cube en 3D dimensions, nous avons maintenant tous les éléments nécessaires pour comprendre le code...ou presque.

Pour toutes les transformations 3D nous avons l'objet TMatrix3d
type
  TMatrix3d = class
    Values: array[0..15] of Double; // facteurs courants de la matrice
    M     : array[0..15] of Double; // Matrice identique utilisée pendant les calculs
    V     : array[0..15] of Double; // Matrice temporaire utilisée pendant les calculs
    x,y,z : Double;                 // le point 3D transformé 
    constructor Create;
    procedure Mult();               // Combine Values et M dans V, puis copie V dans Values
    procedure Translate(tx, ty, tz: Double); // Applique une translation 3D
    procedure RotateX(A: Double);            // Applique une rotation selon l'axe des X
    procedure RotateY(A: Double);            // Applique une rotation selon l'axe des Y
    procedure Transform(ax, ay, az: Double); // Applique les transformations à un point 3D
    procedure Transform2d(ax, ay: Double);   // Calcule la projection 2D du point de l'espace 3D
  end;

Les matrices M et V sont des variables de travail qui évitent d'initialiser une matrice pour chaque calcul.
procedure TMatrix3d.Translate(tx, ty, tz: Double);
begin
  M[12] := tx;
  M[13] := ty;
  M[14] := tz;
  Mult();
  M[12] := 0;
  M[13] := 0;
  M[14] := 0;
end;
La méthode Translate utilise la matrice identique M pour indiquer une translation, après un appel à Mult qui se charge de la combinaison, elle remet les valeurs initiales dans M pour la prochaine transformation.

La méthode Transform2d a la particularité de transformer en coordonnées écran, un point 3D dont la coordonnées Z est nulle.
procedure TMatrix3d.Transform2d(ax, ay: Double);
begin
  x := ax * Values[0] + ay * Values[4] + {az * Values[ 8] +} Values[12];
  y := ax * Values[1] + ay * Values[5] + {az * Values[ 9] +} Values[13];
  z := ax * Values[2] + ay * Values[6] + {az * Values[10] +} Values[14];
  x := 128 * x / (128 + z / 4);
  y := 128 * y / (128 + z / 4);
end;
Cette méthode est une astuce particulière au projet, chaque cube est en effet constitué de six plans 3D sur lesquels sont dessinés les points du dé et le Logo Execute. Ces points sont donc en position Z=0 par rapport à la face, la face subit une rotation pour la placer du bon côté du dé, et une translation pour donner au dé son épaisseur.
constructor TFace.Create(Parent: MovieClip; Depth, ASize: Integer; Rx, Ry: Double);
begin
  inherited Create(Parent, 'face' + IntToStr(Depth), Depth);
  Size  := ASize;
  Matrix := TMatrix3d.Create;
  Matrix.Translate(0, 0, ASize); // on déplace la face afin de donner une épaisseur au dé
  Matrix.RotateX(Rx); // puis on l'oriente
  Matrix.RotateY(Ry);
end;

Les faces sont orientées dans le constructor du Dé
constructor TDice.Create(Depth, Size: Integer);
begin
  inherited Create(nil, 'cube' + IntToStr(Depth), Depth);
  _x := 128;
  _y := 128;
  Faces[0] := TLogoFace.Create(Self, 1, Size, 0  , 0); // la face "1" est un logo, c'est la face de référence, sans rotation
  Faces[1] := TDotedFace.Create(Self, 2, Size, Math.PI/2, 0);  // la face "2" est à 90° sur la droite
  Faces[2] := TDotedFace.Create(Self, 6, Size, Math.PI  , 0);  // la face "6" est à 180° (de l'autre côté du dé)
  Faces[3] := TDotedFace.Create(Self, 5, Size,-Math.PI/2, 0);  // la face "5" est à 90° sur la gauche
  Faces[4] := TDotedFace.Create(Self, 4, Size, 0, +Math.PI/2); // la face 4 est au dessus
  Faces[5] := TDotedFace.Create(Self, 3, Size, 0, -Math.PI/2); // la face 3 en dessous
end;

avec ces matrices de transformation spécifiques à chaque face, il est possible de créer des fonctions moveTo et lineTo qui dessinent, en 2D, dans le plan 3D de la face
procedure TFace.lineTo2d(x, y: Double);
begin
  Matrix.Transform2d(x, y); // projection du point 3D (x, y, 0) du plan 3D de la face vers le plan 2D de l'écran
  lineTo(Matrix.x, Matrix.y); // dessin effectif du trait à l'écran
end;

Le problème des faces cachées

Il reste un dernier point à aborder pour terminer cet article, le problème des faces cachées. Si je dessine les 6 faces du dé dans l'ordre de leur création, j'aurais toujours la même face en avant plan alors qu'elle pourrait se trouver à l'arrière du dé.

Pour éviter cela, il faut gérer "les faces cachées" et déterminer quelle face doit être au dessus des autres.

C'est un vaste sujet, que nous pourrons simplifier dans notre cas puisqu'il n'est question que d'un cube et non d'un objet complexe avec qui plus est des faces transparentes http://tothpaul.free.fr/img/kerolamp.jpg.

Notez que les cartes graphiques modernes, exploitent un principe très simple pour gérer cela avec le ZBuffer. Ce buffer est un tableau de la taille de l'image écran qui conserve pour chaque pixel dessiné sa position en Z. A chaque fois qu'on voudra dessiner un nouveau pixel, il suffira de vérifier que sa position est inférieure à celle du précédent pixel faute de quoi il n'est pas affiché.

Rien de tout cela dans Flash (version 8), il va falloir trouver une autre solution. Comme je le disais, nous avons affaire ici à un simple cube à 6 faces qui ne se chevauchent pas; on pourra se contenter de comparer la position Z du centre de chaque face, s'il est inférieur à 0 c'est que la face a tourné suffisamment pour se trouver face à l'écran, sinon elle est derrière.
procedure TFace.onEnterFrame;
begin
  Matrix.RotateX(-Math.PI / 30); // application une rotation à chaque Frame
  Matrix.RotateY(+Math.PI / 60);
  Matrix.transform(0, 0, 0); // calculer la position du centre de la face
  Clear();
  if Matrix.z < 0 then // si z < 0 alors la face est devant l'écran, sinon  elle est à l'arrière
  begin
    swapDepths(100000 - Matrix.z * 100); // place la face à cette profondeur
    Render; // et la dessiner
  end;
end;

swapDepths() est la seule méthode que Flash nous laisse pour réordonner les clip à l'écran, on pourrait aussi garder l'ordre des clips et déterminer sur lequel doit se dessiner chaque face. Le calcule est repris d'un exemple trouvé sur le web, mais -Matrix.z devrait tout autant faire l'affaire à la réflexion :)

Conclusion

Voila, la 3D sous Flash 8 n'est pas simple, d'autant que je n'aborde pas ici la question des textures 3D. Si leur usage est possible il reste compliqué dans la mesure ou Flash ne propose pas de déformation dans l'espace.

Si je trouve le temps (et/ou l'argent) pour développer FlashPascal 3 (pour Flash 10) nous pourrons développer de la 3D avec OpenGL sous Flash :)
Date de dernière modification : 14/06/2013