Série 8 – Visualiseur de Mandelbrot

Introduction

L'ensemble de Mandelbrot est un ensemble de points du plan complexe à partir duquel il est possible d'obtenir des images fractales intéressantes. Le but de cette série est de réaliser un programme graphique permettant son exploration.

Squelette

Pour faciliter votre travail, nous mettons à votre disposition une archive Zip contenant un squelette de projet composé des classes suivantes :

  • Rectangle, une classe immuable représentant un rectangle,
  • Image, une interface fonctionnelle représentant une image continue (à compléter),
  • ImageComponent, un composant Swing permettant d'afficher une partie d'une image continue (à compléter),
  • ImageViewer, le programme principal créant l'interface graphique, composée d'une fenêtre ne contenant rien d'autre qu'un composant ImageComponent.

L'interface Image est très similaire à celle de la série Images continues, mais avec une différence importante : la couleur retournée par la méthode apply de l'image est représentée de manière « empaquetée », comme décrit dans le cours sur les entiers (§8.1). Cela signifie que la couleur d'un point n'est plus représentée par une instance d'une classe ColorRGB mais comme un entier de type int contenant les trois composantes empaquetées, chacune représentée par 8 bits : la composante rouge occupe les bits 23 à 16, la verte les bits 15 à 8 et la bleue les bits 7 à 0.

Pour vous permettre de bien faire le lien entre ces deux représentations des images continues, l'interface Image fournie contient également la définition d'une image nommée RED_CIRCLE, qui est l'équivalent de l'image du même nom de la série Images continues.

Exercice 1

Complétez le corps de la méthode paintComponent de ImageComponent. Cette méthode dessine dans le composant la zone de l'image continue délimitée par le rectangle stockée dans l'attribut imageBounds de la classe.

Le dessin se fait par l'intermédiaire de l'image — discrète et finie — stockée dans la variable discreteI, déjà définie dans le squelette fourni. La couleur de chacun de ses pixels doit être définie au moyen de la méthode setRGB. Bien entendu, la couleur en question est obtenue de l'image continue à représenter, au moyen de sa méthode apply. Cela est fait de manière à ce que le pixel de coordonnées (0,0) — c-à-d dans le coin haut-gauche de l'image — ait la couleur correspondant au point de l'image continue se trouvant au coin haut-gauche du rectangle imageBounds, et ainsi de suite.

N'oubliez pas que les images continues sont définies dans le plan, dont l'axe des ordonnées est dirigé vers le haut. Par contre, les images discrètes de la bibliothèque Java — et les composants Swing — ont un repère dont l'origine se situe dans le coin haut-gauche et dont l'axe des ordonnées est dirigé vers le bas. Au besoin, consultez les figures 2 et 8 de la série Images continues, ainsi que la dernière image de son corrigé, pour vous rafraîchir la mémoire.

Si cet exercice vous pose trop de problèmes, jetez un œil à la classe ImageComponent de la série Images continues, qui fonctionne selon un principe similaire.

Une fois la méthode paintComponent terminée, lancez le programme et vérifiez que vous obtenez bien l'image ci-dessous. Notez que le cercle est excentré car le centre de l'image ne correspond pas à l'origine du plan. Cela est volontaire, la zone choisie étant idéale pour l'image définie dans le cadre du prochain exercice.

red-circle.png

Figure 1 : Un cercle rouge excentré

Exercice 2

Il existe différents moyens d'obtenir une image à partir de techniques de calcul de l'ensemble de Mandelbrot. Celle que nous utiliserons fait correspondre au point du plan de coordonnées \((x, y)\) la couleur — une teinte de gris — dont les trois composantes sont données par la fonction \(p\) définie ainsi :

\begin{align*} p(x,y) = 1 - \left(\frac{m(x, y)}{M}\right)^\frac{1}{4} \end{align*}

Dans cette formule, \(M\) est une constante nommée nombre maximal d'itérations et \(m\) est une fonction du point dont on désire calculer la couleur, dont la valeur est toujours un entier positif inférieur ou égal à \(M\).

Pour déterminer la valeur de la fonction \(m\) pour un point de coordonnées \((x, y)\), il convient de considérer ce point comme un nombre complexe \(c\) valant \(x + yi\). Cela fait, on calcule les termes de la suite complexe définie ainsi :

\begin{align*} z_0 &= 0\\ z_{n+1} &= z_n^2 + c \end{align*}

jusqu'à ce que l'un d'eux ait un module supérieur ou égal à 2, ou que l'on atteigne le terme d'indice \(M\). L'indice du dernier terme calculé est la valeur de la fonction \(m\) pour ce point.

Par exemple, le point de coordonnées (0.9, 0) correspond au nombre complexe 0.9 (sans partie imaginaire). Les termes de la suite ci-dessus pour ce nombre complexe valent :

\begin{align*} z_0 &= 0\\ z_1 &= z_0^2 + 0.9 = 0.9\\ z_2 &= z_1^2 + 0.9 = 1.71\\ z_3 &= z_2^2 + 0.9 \approx 3.82 \end{align*}

A ce stade, on a un terme dont le module est supérieur ou égal à 2, et il convient donc d'arrêter le calcul de la suite et de conclure que \(m(0.9, 0) = 3\). En admettant que \(M\) vaille 500, la teinte de gris associée au point (1, 0) est donc :

\begin{align*} p(0.9, 0) = 1 - \left(\frac{m(0.9, 0)}{M}\right)^\frac{1}{4} = 1 - \left(\frac{3}{500}\right)^\frac{1}{4} \approx 0.72 \end{align*}

Dès lors, la couleur correspondant au point (0.9, 0) est (0.72, 0.72, 0.72), un gris clair : :-).

Un autre exemple est celui de l'origine (0, 0), qui correspond au nombre complexe 0. Les termes de la suite ci-dessus pour ce nombre complexe valent tous 0 :

\begin{align*} z_0 &= 0\\ z_1 &= z_0^2 + 0 = 0\\ z_2 &= z_1^2 + 0 = 0\\ &\ldots\\ z_{500} &= z_{499}^2 + 0 = 0 \end{align*}

Dès lors, aucun terme de la suite n'a de module supérieur ou égal à deux, et on arrête de les calculer lorsqu'on a atteint le terme d'indice \(M\). On en conclut que \(m(0, 0) = M = 500\) et donc que la teinte de gris associée à l'origine est :

\begin{align*} p(0, 0) = 1 - \left(\frac{m(0, 0)}{M}\right)^\frac{1}{4} = 1 - \left(\frac{500}{500}\right)^\frac{1}{4} = 0 \end{align*}

Dès lors, la couleur correspondant au point (0, 0) est (0, 0, 0), c-à-d noir : :-).

Ajoutez à l'interface Image une méthode statique prenant en argument le nombre maximal d'itérations \(M\) et retournant une image de Mandelbrot calculée selon la technique décrite à l'instant. Cette méthode a le profil suivant :

public interface Image {
  public static Image mandelbrot(int maxIterations) {
    // ... à faire
  }
}

Attention : pensez à transformer la valeur réelle \(p\) produite par la formule ci-dessus et comprise entre 0 et 1 en une (ou plutôt trois) composantes couleur entières comprises entre 0 et 255, que vous empaquetterez en un entier int.

Notez que pour calculer les termes de la suite, une solution assez standard serait de définir tout d'abord une classe représentant les nombres complexes. Toutefois, il est en pratique plus simple — et passablement plus rapide — de représenter les nombres complexes par une paire de nombres réels de type double, un pour la partie réelle, l'autre pour la partie complexe.

Une fois cette image définie, modifiez la classe ImageViewer pour passer au composant ImageComponent une image de l'ensemble de Mandelbrot avec un nombre maximum d'itérations valant 500. Vous devriez alors obtenir l'image ci-dessous.

mandelbrot.png

Figure 2 : Représentation de l'ensemble de Mandelbrot en teintes de gris

Exercice 3

Pour permettre l'exploration de l'ensemble de Mandelbrot, ajoutez au composant ImageComponent la possibilité de zoomer en double-cliquant sur le composant.

Pour ce faire, attachez — au moyen de la méthode addMouseListener — un auditeur d'événements souris au composant. Cet auditeur, une classe instanciable héritant de MouseAdapter, se contente de redéfinir la méthode mouseClicked de sa super-classe. Cette redéfinition commence par vérifier — au moyen de la méthode getClickCount — que l'utilisateur a bien effectué un clic double. Si tel est le cas, elle change la zone de l'image continue affichée par le composant de manière à ce que :

  1. les dimensions (largeur et hauteur) de la nouvelle zone soient égales à la moitié de celles de l'ancienne zone, ce qui correspond à un facteur de zoom de 2,
  2. le point P sur lequel le pointeur de la souris se trouvait au moment du clic — dont les coordonnées s'obtiennent au moyen des méthodes getX et getY — se retrouve au même endroit dans le composant après le zoom.

Ce comportement correspond, entre autres, à celui des systèmes de cartographie en ligne comme OpenStreetMap ou Google Maps. Une solution relativement simple pour l'obtenir consiste à transformer le rectangle contenant la zone de l'image continue à afficher en trois étapes successives :

  • premièrement, le rectangle est translaté afin de placer son coin bas-gauche sur le point P,
  • deuxièmement, le rectangle est redimensionné du facteur voulu (ici 1/2), en conservant la position de son coin bas-gauche,
  • troisièmement, le rectangle est à nouveau translaté afin de replacer le point P à sa position initiale, dans le repère du composant.

Les méthodes translatedBy et scaledBy de la classe Rectangle permettent d'effectuer les translations et redimensionnements requis. La principale difficulté ici est de correctement déterminer les paramètres des deux translations. Faites un dessin pour vous faciliter le travail !

Si le comportement susmentionné vous paraît trop difficile à mettre en œuvre, vous pouvez en choisir un autre. Par exemple, un clic double peut zoomer en conservant la position du centre de l'image, tandis qu'un clic simple peut déplacer l'image afin que le point cliqué se retrouve au centre. Peu importe le comportement exact, l'important est de permettre le zoom dans l'image.

Une fois cet auditeur défini, vérifiez qu'il fonctionne en essayant de zoomer dans l'image de l'ensemble de Mandelbrot. En vous y promenant, vous devriez découvrir de nombreuses images intéressantes, comme celle ci-dessous.

mandelbrot-zoomed.png

Figure 3 : Zoom dans la représentation de l'ensemble de Mandelbrot