Corbac

Nuit étoilée

Après une longue session de Starbound, j’ai eu envie de coder un générateur de cieux étoilés. Un an et demi plus tard, je décide de partager le code source et d’expliquer les étapes de conception. Enfin, j’essaye, car ma mémoire me fait souvent défaut. Le code est disponible sur mon Github.

Ciel 216

Je l’ai codé en Python. Il s’agit de mon langage préféré pour sa simplicité. J’avais déjà testé quelques libraries graphique me permettant de partir confiant sur ce projet.

Je suis parti d’une classe, sans aucun diagramme de conception, où je viendrais appeler différentes méthodes symbolisant la progression de la construction du ciel. Pour être honnête, je ne voulais pas que ce projet me prenne trop de temps, et sachant que je suis encore un noob en OOP, on peut considérer le code que je vais présenter comme un blasphème que je n’assume qu’à moitié.

Ce n’était pas prévu, mais je me suis basé sur la génération procédurale avec une seed. Il est ainsi possible de retrouver la même image si l’on indique en entrée du programme le nombre choisi.

Nous partons d’un wallpaper 1920×1080 entièrement noir.

from PIL import Image, ImageDraw, ImageFilter
import random

class drawer:
    img_x = 1920  # Width
    img_y = 1080  # Height
    image = None  # Result
    seed = 0  # Seed

    def __init__(self, seed=0):
        self.image = Image.new("RGB", (self.img_x, self.img_y))

        self.seed = seed
        random.seed(self.seed)

Je ne pense pas que je vais entrer dans les détails pour les explications suivantes, car après autant de temps passé, certaines idées ne sont pas restées. Je vais tenter de rester clair et d’indiquer les grandes lignes.

Créer des étoiles

La première étape était de créer un amas de pixels formant une étoile. Je me suis aidé de la forme d’une astroïde et de la gaussienne.

L’idée est de travailler sur une matrice carrée de pixels de taille \(n\). On y viendra dessiner notre astroïde à l’intérieur de cette grille, avec une intensité variable : Elle est forte au centre de l’étoile et s’affaiblit lorsque l’on s’en éloigne.

Je viens utiliser la Gaussienne sous la forme d’une loi normale avec comme paramètres \(\mu = 0\) et \(\sigma = \frac{n}{6}\). On peut noter que je n’utilise pas exactement la même fonction. J’omets volontairement le coefficient de l’exponentielle, car je ne m’intéresse pas tout de suite à son intensité. Je définis cette fonction deux fois pour qu’elle puisse être utilisée dans \(\mathbb{R}^2\).

Je viens dessiner l’astroïde à l’aide des courbes de Lamé de paramètre \(n=2/3\). Je multiplie la valeur obtenue par celle de la gaussienne de la case associée.

Pour revenir sur \(\mathbb{R}\), je viens faire la moyenne géométrique entre les deux intensités trouvées pour la même cellule de la matrice. On obtient un disque flouté qui se multiplie avec une astroïde pure. L’étoile est bien dessinée.

def draw_star(self, x, y, n):
    def gaussian(x, mu, sig):
        """Gaussian bell function"""
        return np.exp(-np.power(x - mu, 2.0) / (2 * np.power(sig, 2.0)))

    def astroid(a, x):
        """Lamé curve"""
        def sqrt3(n):
            return n ** (1 / 3)
        return ((sqrt3(a ** 2) - sqrt3(x ** 2)) ** 3) ** (1 / 2)

    # Generate a random blue color
    colors = [random.randint(192, 255) for i in range(3)]
    # Apply the mathematical functions on a grid of n*n pixels
    for j in range(n):
        for i in range(n):
            intensityX = gaussian(-int(n / 2) + j, 0, n / 6)
            intensityY = gaussian(-int(n / 2) + i, 0, n / 6)
            intensityX *= astroid(int(n / 2), -int(n / 2) + j)
            intensityY *= astroid(int(n / 2), -int(n / 2) + i)

            intensity = (intensityX * intensityY) ** (1 / 2) / int(n / 2)
            grey = int(intensity * 255)
            coord = (
                (x - int(n / 2) + j) % self.imgx,
                (y - int(n / 2) + i) % self.imgy,
            )

            pixel = tuple(
                (max(self.image.getpixel(coord)[i], int(colors[i] * intensity)))
                for i in range(3)
            )
            # Create the pixel
            self.image.putpixel(coord, tuple(pixel))

Oui bon, je n’ai pas précisé, mais j’ai fait un petit bidouillage pour changer légèrement la teinte de chaque étoile, sinon on avait que des blanches.

Fond nébuleux

C’était mon dernier ajout. Il s’agit d’un bête bruit de Perlin. On génère le bruit souhaité, on choisit une couleur, et on l’applique sur le fond noir. Pas grand-chose d’intéressant ici, à part peut⁻être que mon programme génère une matrice de la taille de l’image afin de stocker les intensités du bruit généré. Je garde en mémoire la matrice, car elle servira juste après.

import numpy as np
import noise

def generate_perlin_array(
        self, scale=3000, octaves=6, persistence=0.5, lacunarity=2.0
    ):
    """Generate a Perlin image"""

    shape = (self.imgx, self.imgy)
    arr = np.zeros(shape)
    for i in range(shape[0]):
        for j in range(shape[1]):
            arr[i][j] = noise.pnoise2(
                i / scale,
                j / scale,
                octaves=octaves,
                persistence=persistence,
                lacunarity=lacunarity,
                repeatx=1024,
                repeaty=1024,
                base=self.seed,
            )

Répartition des étoiles dans le ciel

En s’aidant de la matrice du bruit de Perlin, elle servira de loi de probabilité d’apparition des étoiles. Afin de casser, artistiquement parlant, le lien entre les tâches nébuleuses et les étoiles de l’image, on vient retourner le bruit de 180°. On aurait pu recréer une nouvelle matrice, mais cela aurait encore augmenté le temps d’attente lors de la génération.

Il y a également plusieurs tailles d’étoiles en fonction d’une loi de probabilité sur plusieurs étages. Les valeurs choisies sont arbitraires. Ainsi, on vient multiplier nos deux lois de probabilités entre elles, pour obtenir cette drôle de loi de probabilité finale.

def generate_sky(self):
    for x in range(self.imgx):
        for y in range(self.imgy):
            p = random.randint(0, self.prob_scale)
            if p <= int(self.per[x][y] * 0.00001):
                self.draw_star(x, y, 18)
                self.constellation_star_array.append((x, y))
            elif p <= int(self.per[x][y] * 0.00004):
                self.draw_star(x, y, 12)
                self.constellation_star_array.append((x, y))
            elif p <= int(self.per[x][y] * 0.0002):
                self.draw_star(x, y, 6)
            elif p <= int(self.per[x][y] * 0.0004):
                self.draw_star(x, y, 4)

Création de constellations

Dans Animal Crossing : Wild World, on pouvait dans l’observatoire de Céleste créer des constellations avec les étoiles dans le ciel. J’ai décidé de tenter d’en générer. Je me suis posé quelques règles simples.

La description est vague. J’avoue y être allé par tâtonnements.

J’ai préalablement enregistré les positions des plus grosses étoiles générées précédemment. Avec ces données, je mets en place un algorithme qui cherchera à faire des groupes d’étoiles relativement homogène. Mon choix s’est porté sur DBSCAN. Une fois mes groupes formés, je supprime les plus petits. J’ai pris la valeur arbitraire qu’un groupe devait avoir au moins 4 étoiles pour former une belle constellation. J’ai également paramétré de façon empirique l’algorithme pour ne pas avoir de trop gros groupes.

Avec mes différents groupes, il faut maintenant dessiner les liens entre les étoiles. Plutôt que de tous les dessiner, j’ai opté sur une méthode de triangulation, notamment celle de Delaunay. Cela permet de respecter la seconde problématique, celle où tous les liens ne se traversent pas.

Enfin, on vient supprimer aléatoirement certains liens à l’aide d’un parcours entre les étoiles. On peut ainsi trouver des formes variées, tout en s’assurant que le graphe reste connexe et élégant. J’avais essayé avec d’autres algorithmes, mais esthétiquement parlant, ce DFS un peu beaucoup custom rendait le meilleur résultat.

“Dessine-moi un mouton”

Dans mon cas, c’était un peu plus grand. Je voulais remobiliser la plupart de mes connaissances en mathématiques pour résoudre ce problème artistique. L’écriture de cet article m’a permis de relire un peu de code, et surtout de tenter de comprendre ce que je voulais faire par le passé. Malgré les quelques lignes de commentaires, ça n’a pas été suffisant, et il y a encore quelques zones d’ombres que je ne saurais aujourd’hui justifier.

Je n’ai aucune envie aujourd’hui de continuer le projet. J’en garde cependant un excellent souvenir. Je ne pense pas avoir eu de grande difficulté à le réaliser, mais si je devais recommencer, je règlerais les problèmes d’optimisation en priorité. Python, c’est sympa, mais la génération est vraiment lente. Je devrais passer par du multi-processing pour gagner en temps. Je vois aussi que je ne faisais pas attention à la mémoire utilisée, ou que la phase de dessin était éparpillée un peu partout dans le programme. Aussi, l’utilisation plus avancée des libraries graphiques et mathématiques serait plus judicieux, en trouvant autre chose que PIL et exploitant davantage numpy.

Le ciel qui se trouve au-dessus de ma tête ne change pas beaucoup de forme en fonction du temps, mais trop souvent je le vois noir et sans étoile. N’étant pas souvent à la campagne, je trouvais réconfortant d’avoir un semblant de naturel dans cette œuvre artificielle.

Au final, je devais vraiment être triste ces jours-là.