Générateur d'images fractales (6)

03032009 Dans: Pas à pas

Nous continuons aujourd'hui l'optimisation de la génération de l'image.

Les bitmaps

J'ai vaguement expliqué ce qu'était une bitmap, me contentant de dire qu'il s'agissait d'une grille de pixels. Intéressons-nous à leur organisation en mémoire.

32 bits par pixel

Utiliser 32 bits pour stocker les composantes d'un pixel est des plus classiques:

Mode 32 bits/pixel

Chaque composante utilise un octet, et peut donc contenir une valeur de 0 à 255. Doser les quantités de rouge, de vert et de bleu permet de choisir la teinte du pixel; la composante alpha correspond à l'opacité du pixel.

256 niveaux de gris

Notre générateur utilise une bitmap en 256 niveaux de gris:

Mode 8 bits/pixel

C'est le même principe: la valeur de l'octet fournit la nuance; 0 correspond au noir et 255 au blanc, voilà pourquoi nous utilisons la ligne:

NSUInteger nuance = n * 255 / MAX_ITERATIONS;

pour déterminer la nuance du pixel calculé.

Organisation en mémoire

Voyons maintenant la relation entre les pixels et les coordonnées:

Bitmap en 256 niveaux de gris

Dans cet exemple, la bitmap mesure 400 pixels de large et 300 de haut. Ce qui est intéressant, c'est que les pixels se suivent en mémoire. Ainsi, si nous disposons de l'adresse à laquelle est stockée la bitmap, adresseBitmap:

  • Le pixel de coordonnées (0,0) se trouve à l'adresse adresseBitmap.
  • Le dernier pixel de la première ligne (399, 0) se trouve à l'adresse adresseBitmap + 399 (puisqu'un pixel prend exactement un octet).
  • Le premier pixel de la deuxième ligne (0, 1), se trouve à l'adresse adresseBitmap + 400.
  • etc.

En généralisant nous obtenons:

adressePixel = adresseBitmap + (400 * y) + x

ou en généralisant d'avantage:

adressePixel = adresseBitmap + (largeurBitmap * y) + x

Il nous suffit d'écrire la nuance à cette adresse pour modifier le point. Vous devez maintenant avoir une bonne idée du fonctionnement de la méthode -[NSBitmapImageRep setPixel:atX:y];

Row Bytes

À vrai dire, j'ai simplifié la figure précédente, en omettant un détail. Voici une figure plus juste:

Bitmap en 256 niveaux de gris + rowBytes

En effet, pour des raisons de performances, des octets inutilisés (row bytes) sont ajoutés à la fin de chaque ligne. Ils servent à aligner la bitmap en mémoire. On ne connaît pas leur nombre a priori: cela dépend de plusieurs paramètres.

Toujours est-il qu'il faut en tenir compte.

L'optimisation

Dans notre boucle de rendu, il ne nous reste plus grand chose que nous puissions améliorer si ce n'est cette ligne:

[bitmapRep setPixel:&nuance atX:x y:y];

Cette méthode est tout de même appelée un million de fois (pour notre rendu en 1000 x 1000 pixels). Les bénéfices attendus en écrivant directement dans la bitmap sont les suivants:

  • Ne plus appeler la méthode [setPixel:atX:y:]. Les appels de méthodes sont encore plus lents que les appels de fonctions. Nous ferons entre-autres l'économie de l'exécution de la fonction objc_msgSend_rtp().
  • La méthode [setPixel:atX:y:] est forcément plus compliquée qu'un écriture directe dans la bitmap, ne serait-ce que par son côté généraliste. De plus, si vous vous rappelez l'article précédent, Shark listait des appels aux méthodes
    -[NSBitmapImageRep setCGImageRef:]
    -[NSBitmapImageRep getBitmapDataPlanes:]
    -[NSBitmapImageRep
    acquireRetainesCGImageRef]
    Je pense que ceci va nous permettre de nous en passer.

Le code

Inverser les énumérations des x et y

À cause de la manière dont est stockée la bitmap, il est nécessaire les énumérations de x et y :

for(y = 0; y < hauteurBitmap; y++)
{       
    for(x = 0; x < largeurBitmap; x++)
    {

                …

        c.reel += incX;
    }

    c.reel = premierPoint.reel;
    c.imag += incY;
}

Adresser la bitmap

Avant le calcul:

// Obtenir la bitmap
unsigned char* bitmapPtr = [bitmapRep bitmapData];

On demande l'adresse de la bitmap.

unsigned int rowBytes = [bitmapRep bytesPerRow] - largeurBitmap;

La méthode [bitmapRep bytesPerRow] renvoie le nombre d'octets utilisés pour stocker une ligne. Comme nous savons que largeurBitmap octets sont utiles, nous en déduisons le nombre d'octets d'alignement (rowBytes).

for(y = 0; y < hauteurBitmap; y++)
{       
    for(x = 0; x < largeurBitmap; x++)
    {

                    …

        // Donner le niveau de gris au pixel
        *bitmapPtr = n * 255 / MAX_ITERATIONS;

Nous conservons la formule du calcul de la nuance, que nous écrivons dans la bitmap à l'adresse du pixel courant.

        bitmapPtr++;

Puis nous passons au pixel suivant.

        c.reel += incX;
    }

    c.reel = premierPoint.reel;
    c.imag += incY;
    bitmapPtr += rowBytes;  // Sauter les octets inutilisés en fin de ligne

À la fin de chaque ligne, nous sautons les octets inutilisés.

}

Résultat

50 images rendues en 12.864695 secondes.
Moyenne = 3.886606 images/s

Nous tournons aux alentours de 4 images/s. Je vous rappelle que la première mesure donnait 0,79 images/s… la vitesse a été multipliée par 5 !

Nous arrivons aux limites de ce que nous pouvons ainsi optimiser. Nous allons nous arrêter là.

Et si ce n'était que le début ?

Améliorer la vitesse demanderait maintenant de recourir à des astuces.

Voici une idée: vous pouvez remarquer que deux pixels qui se suivent sont très souvent de la même nuance. On pourrait ne calculer qu'un pixel sur deux:

  • Si le pixel n°3 n'est pas de la même nuance que le pixel n°1, alors on calcule le pixel 2
  • Sinon, on lui donne la nuance du pixel 3.

On ne manquerait que des variations brusques (<1 pixel), peu perceptibles.

Cependant, tout cela est sans intérêt, parce que la vraie façon d'améliorer la vitesse est de déléguer le calcul… à la carte graphique! Je crois que n'importe quelle carte est aujourd'hui capable de faire ce calcul en temps réel. Ce sera peut-être pour une prochaine fois (quand j'aurais appris à me servir d'OpenGL Shading Language). En attendant, l'application est utilisable. Nous allons pouvoir nous balader dans l'ensemble de Mandelbrot.

À bientôt pour la suite.

Le projet XCode complet à télécharger.

Renaud Pradenc
Céroce.com

Générateur d'images fractales (3)

10022009 Dans: Pas à pas

Après la génération de notre premier image dans l'article précédent, nous allons remanier notre programme pour le rendre plus propre.

Remaniement

Le remaniement (en anglais: Refactoring) est une opération qu'il est bon de faire continuellement. Il s'agit :

  • de changer l'architecture de l'application
  • d'améliorer la documentation
  • ou simplement de renommer certaines classes, méthodes ou variables.

Le but est de faciliter les modifications à venir, en gardant le code le plus propre possible tout au long de la vie du logiciel. Notez bien que nous n'ajoutons pas de fonctionnalités.

Isoler les fonctions sur les nombres complexes

Les fonctions mathématiques peuvent être réutilisées à divers endroits de notre programme, et pas seulement pour la calcul de l'image fractale, nous allons donc les séparer:

  • Créez un nouveau source C, appelé CFRComplexe.c (menu File > New File > C and C++ > C File).
  • Déplacez la définition de la structure ainsi que le prototype de Carre() dans CFRComplexe.h.
  • Déplacez la fonction Carre() dans CFRComplexe.c.

Pendant que nous y sommes, nous créons la fonction ModuleAuCarre():

double ModuleAuCarre(Complexe_t z)
{
    return z.reel*z.reel + z.imag*z.imag;
}

N'oubliez pas de rajouter son prototype dans le .h.

Isoler le calcul de l'image fractale

Les calculs appartenant à la couche Modèle, nous allons créer un objet CFRMandelbrotRender qui ne fait que générer la bitmap:

@interface CFRMandelbrotRender : NSObject {

}

- (NSBitmapImageRep*) bitmapImageRep;
@end

Déplaçons dans CFRMandelbrot.m tout le code qui fait le calcul de la bitmap:

- (NSBitmapImageRep*) bitmapImageRep
{
    // Créer la bitmap
    NSBitmapImageRep* bitmapRep = 
        [[NSBitmapImageRep alloc]
        initWithBitmapDataPlanes:NULL
        pixelsWide:400
        pixelsHigh:300
        bitsPerSample:8
        samplesPerPixel:1
        hasAlpha:NO
        isPlanar:NO
        colorSpaceName:NSDeviceWhiteColorSpace
        bytesPerRow:0
        bitsPerPixel:8];

    // Calculer l'ensemble de Mandelbrot:
    // Parcourir tous les points de la bitmap
    double x, y;
    for(x = 0; x < 400; x++)
    {
        for(y = 0; y < 300; y++)
        {
            // Convertir les coordonnées
            Complexe_t c;
            c.reel =  x/100.0 - 2;
            c.imag   = -y/100.0 + 1.5;

            // Initialiser z[0]
            Complexe_t z = {0.0, 0.0};

            NSUInteger n;
            for(n=0; n < MAX_ITERATIONS; n++)
            {
                // z[n+1] = z[n+1]^2 + c
                Complexe_t zCarre = Carre(z);
                z.reel = zCarre.reel + c.reel;
                z.imag = zCarre.imag + c.imag;

                // La suite diverge si |z| > 2
                if(ModuleAuCarre(z) > 4.0)
                    break;
            }

            // Donner le niveau de gris au pixel
            NSUInteger nuance = n * 255 / MAX_ITERATIONS;
            [bitmapRep setPixel:&nuance atX:x y:y];
        }
    }

    [bitmapRep autorelease];
    return bitmapRep;
}

Les modifications sont les suivantes:

  • la nouvelle fonction ModuleAuCarre() est appelée au lieu de faire le calcul en ligne.
  • la méthode [bitmapRep autorelease] est appelée pour que la mémoire occupée par la bitmap soit libérée, mais seulement après que la vue ait pu l'afficher.

Remaniement de la vue

Renommer

La vue pourrait maintenant servir à afficher autre chose que l'ensemble de Mandelbrot, par exemple celui de Julia. Ceci nous pousse à la renommer. Nous allons utiliser l'outil de Refactoring de XCode pour cela:

  • Ouvrez CFRMandelbrotView.m
  • Sur la ligne @implementation CFRMandelbrotView, sélectionnez le texte CFRMandelbrotView
  • Choisissez l'article de menu Edit > Refactor…
  • Le menu pop-up étant sur Rename, tapez le nouveau nom: CFRFractalView
  • Cliquez sur Preview, puis Apply.

Voilà un outil bien pratique, d'autant plus qu'il renomme la classe y-compris dans le fichier XIB !

Fixer la classe de rendu

Mettre à part le code qui fait le rendu nous impose maintenant de créer un lien pour récupérer la bitmap. Ajoutons une outlet à CFRFractalView:

#import "CFRMandelbrotRender.h"

@interface CFRFractalView : NSView {
    IBOutlet CFRMandelbrotRender* render;
}

@end

Basculez sous Interface Builder. Instanciez un exemplaire de CFRMandelbrotRender, et reliez l'outlet render de la vue à cet objet.

Méthode drawRect:

Retournons sous XCode, dans CFRFractalView.m. Ajoutons à la méthode drawRect: le nécessaire pour obtenir la bitmap et l'afficher:

- (void)drawRect:(NSRect)rect
{
    if(render)  // L'outlet est fixée
    {
        // Afficher la bitmap
        NSBitmapImageRep* bitmapRep = [render bitmapImageRep];
        [bitmapRep drawAtPoint:NSMakePoint(0,0)];       
    }

    else    // L'outlet "render" n'est pas fixée
    {
        [[NSColor blueColor] set];
        NSRectFill(rect);
    }

}

J'ai ici gérer le cas où l'on aurait oublier de relier l'outlet render. La vue serait alors emplie de bleu.

Résultat

Nous pouvons à présent lancer le programme: le résultat est très exactement le même. Mission accomplie !

Nous disposons dorénavant d'une bonne base pour poursuivre le développement. À bientôt pour la suite.

Le projet XCode complet à télécharger.

Renaud Pradenc
Céroce.com

Générateur d'images fractales (2)

05022009 Dans: Pas à pas

Cet article est le deuxième d'une série. Je vous recommande de commencer par le premier article, et même de le laisser ouvert pour pouvoir suivre au mieux celui-ci.

Cette fois-ci, nous entrons dans le vif du sujet, avec une première version du générateur. C'est parti !

Mise en place du projet

  • Créez un projet de type Cocoa Document-Based.
    Notre application devra tôt ou tard enregistrer et charger des documents.

  • Créez un fichier source de type Objective-C NSView subclass, appelé CFRMandelbrotView.
    Cette vue personnalisée effectuera le calcul et l'affichage.

  • Ouvrez MyDocument.nib dans Interface Builder. Dans la fenêtre Window se trouve un texte. Effacez-le.

  • Glissez une Custom View dans la fenêtre. Vous la trouverez dans la palette Library > Cocoa > Views & Cells.

  • Dans l'onglet View Size de l'Inspecteur, réglez les dimensions (W et H) de la vue à 400 x 300 pixels.

  • Dans l'onglet View Identity mettez la classe à CFRMandelbrotView.

Enregistrez, et revenez à XCode.
Pour simplifier, c'est la vue qui effectuera le calcul. Nous brisons le paradigme MVC, mais ce n'est pas bien grave pour ce programme d'essai.

Gestion des nombres complexes

Le langage Objective-C ne gère pas les nombres complexes. Nous allons devoir coder cela nous même. Pour commencer, déclarons dans CFRMandelbrotView.h un type "nombre complexe", basé sur une structure:

typedef struct Complexe_s
{
    double reel;    // Partie réelle
    double imag;    // Partie imaginaire
} Complexe_t;

Ajoutez également le prototype de la fonction qui permet d'élever un nombre complexe au carré:

Complexe_t Carre(Complexe_t z);

Passons maintenant à CFRMandelbrotView.m, pour y écrire la fonction:

Complexe_t Carre(Complexe_t z)
{
    Complexe_t carre;

    carre.reel = z.reel*z.reel - z.imag*z.imag;
    carre.imag = 2.0 * z.reel * z.imag;

    return carre;
}

Génération et affichage

Nous allons maintenant écrire la méthode -drawRect:. Cette méthode est appelée à chaque fois qu'il faut rafraîchir la vue. En pratique, dans ce premier programme, elle ne sera appelée que lors du premier affichage.

Création de la bitmap

Les bitmaps sont représentées par la classe NSBitmapRep. Créons une instance et initialisons-là:

NSBitmapImageRep* bitmapRep = [[NSBitmapImageRep alloc]
    initWithBitmapDataPlanes:NULL
    pixelsWide:400
    pixelsHigh:300
    bitsPerSample:8
    samplesPerPixel:1
    hasAlpha:NO
    isPlanar:NO
    colorSpaceName:NSDeviceWhiteColorSpace
    bytesPerRow:0
    bitsPerPixel:8];

Voilà une méthode avec beaucoup de paramètres !

  • Le premier paramètre est mis à NULL. Nous voulons que la méthode réserve la mémoire pour la bitmap elle-même.
  • pixelsWide et pixelsHigh sont les dimensions de la bitmap, en pixels.
  • samplesPerPixel (échantillons/pixel)
    Un échantillon est une composante de la couleur. Par exemple, une image RVB possède trois échantillons: un Rouge, un Bleu et un Vert. Comme nous générons une image en niveaux de gris, il n'y a qu'un échantillon par pixel.
  • bitsPerSample (bits/échantillon)
    Nous générons une image en 256 niveau de gris, ce qui requiert 8 bits/échantillon.
  • hasAlpha
    Indique si l'image possède une couche alpha (opacité). Dans notre cas, non.
  • isPlanar
    Il est possible de gérer plusieurs couches de couleurs séparément (une couche rouge, une verte, une bleu, une alpha). Pas dans notre cas.
  • colorSpaceName (nom de l'espace de couleur)
    Nous utilisons l'espace de couleur NSDeviceWhiteColorSpace, qui nous permet d'avoir la valeur maximale blanche. Vous pourrez essayer avec NSDeviceBlackColorSpace si vous voulez.
  • bytesPerRow (octets par ligne) Nous mettons à 0 pour que la méthode calcule cette valeur elle-même en fonction des autres paramètres.
  • bitsPerPixel
    C'est toujours 8 bits/pixel.

Calcul de l'ensemble

// Parcourir tous les points de la bitmap
double x, y;
for(x = 0; x < 400; x++)
{
    for(y = 0; y < 300; y++)
    {

Nous parcourons tous les pixels de la bitmap.

        // Convertir les coordonnées
        Complexe_t c;
        c.reel =  x/100.0 - 2;
        c.imag = -y/100.0 + 1.5;

Nous convertissons les coordonnées x et y du repère de la bitmap vers celui du plan mathématique.

        // Initialiser z[0]
        Complexe_t z = {0.0, 0.0};

Le calcul de chaque pixel commence par la mise à zéro de c.

        NSUInteger n;
        for(n=0; n < MAX_ITERATIONS; n++)
        {
            // z[n+1] = z[n+1]^2 + c
            Complexe_t zCarre = Carre(z);
            z.reel = zCarre.reel + c.reel;
            z.imag = zCarre.imag + c.imag;

            // La suite diverge si |z| > 2
            double moduleAuCarre = z.reel*z.reel + z.imag*z.imag;
            if(moduleAuCarre > 4.0)
                break;
        }

Nous recherchons ensuite combien il faut d'itérations pour que la suite diverge. Remarquez une optimisation ici: au lieu de tester si le module
√(a^2 + b^2) > 2
nous testons si
(a^2 + b^2) > 4

une racine carrée étant un calcul très coûteux en temps machine. Le code reste toutefois peu efficace: son amélioration est prévue dans un prochain article. La priorité est ici donnée à sa lisibilité.

        // Donner le niveau de gris au pixel
        NSUInteger nuance = n * 255 / MAX_ITERATIONS;

J'ai remanié la formule donnée dans l'article précédent. La division est faite en dernier, ce qui permet de faire tous les calculs sur des entiers (ce qui évite les casts et accélère un peu).

        [bitmapRep setPixel:&nuance atX:x y:y];
    }
}

Et enfin, nous écrivons la nuance dans la bitmap. Vous remarquerez que le premier paramètre de setPixel: est un tableau d'entiers. Étant donné que nous travaillons avec un seul échantillon par pixel, le tableau n'a qu'un seul indice, et il revient donc au même de passer un pointeur sur nuance.

Affichage

Affichons la bitmap dans la vue:

// Afficher la bitmap
[bitmapRep drawAtPoint:NSMakePoint(0,0)];

Rappelez-vous que l'origine de la vue se trouve dans le coin inférieur gauche.

Et finalement, il ne faut pas oublier de libérer la mémoire:

[bitmapRep release];

Le résultat

Lancez le programme. La fenêtre doit s'ouvrir avec l'ensemble dessiné.

L'ensemble généré

C'est beau !

La suite

Essayez différentes valeurs de MAX_ITERATIONS. Le rendu sera différent. Nous ajouterons bientôt des contrôles pour changer les paramètres du calcul.

Pour l'instant, la nature fractale de l'ensemble ne saute pas aux yeux, mais en observant attentivement, on retrouve tout de même un peu la forme de la "tête" sur les deux "ailes".
Une des améliorations à apporter sera justement la possibilité de se déplacer dans le plan, en particulier, de zoomer.

Le projet XCode complet à télécharger.

Renaud Pradenc
Céroce.com