devblog
16 min de lecture

ChunkGo : tester MyGo en recréant les seeds de Minecraft

Pour mettre à l'épreuve MyGo, un tout nouveau framework desktop en Go, j'ai recréé le principe des seeds de Minecraft : un seul nombre, un monde infini. Bruit de Perlin, chunks, rendu isométrique, et ce que vaut MyGo une fois les mains dedans.

Go 1.27MyGo 0.2.3TypeScriptCanvas 2DWebView2

Le point de départ, c'est MyGo : un framework tout neuf (v0.2) pour faire des apps desktop en Go avec une interface web. Je voulais le tester sur autre chose qu'un « hello world », avec un projet qui le pousse un peu : du calcul lourd côté Go, beaucoup de dessin côté page, et un gros volume de données entre les deux.

Comme sujet, j'ai pris un truc qui m'intrigue depuis longtemps : les seeds de Minecraft. On tape un mot ou un nombre à la création du monde, et ce seul nombre suffit à tout redonner : le relief, les océans, les biomes, chaque arbre. Le jeu ne stocke rien d'avance, il recalcule le terrain à partir de la seed au fur et à mesure qu'on avance, et deux joueurs qui tapent la même seed se baladent dans le même monde.

ChunkGo met ça en scène. On tape une seed, Go calcule le terrain sur tous les cœurs et l'envoie chunk par chunk à la page, qui le dessine en vue isométrique. L'article suit le chemin des données : du bruit de Perlin au relief, du relief au monde infini, du monde à l'écran. La dernière partie, c'est mon avis sur MyGo, avec deux bugs que j'ai corrigés dans le framework en passant.

CAPTURE_01 — UN MASSIF EN (318, −212), EXPORTÉ PAR L'APP
Un massif enneigé en vue isométrique, généré par ChunkGo
Seed « mygo », réglage Montagnes, 8 × 8 chunks. L'image a été enregistrée par le bouton « Enregistrer en PNG » de l'app : le canvas part en Go, qui ouvre un dialogue natif et écrit le fichier.

32 µs

pour un chunk, sur un cœur

~1,5 ms

pour 64 chunks, sur 16 cœurs

8,6 Mo

l'exécutable complet

L'app en deux mots

CAPTURE_02 — CHUNKGO
L'interface de ChunkGo : réglages à gauche, monde isométrique au centre, statistiques en bas
À gauche, tous les réglages du générateur. Au centre, le monde autour de la caméra : autant de chunks qu'il en faut pour remplir la fenêtre. En bas, ce que la dernière génération a coûté.

On choisit une seed (un nombre ou n'importe quel texte), on règle le bruit, et le monde se recalcule. On se déplace en glissant ou au clavier : les chunks qui entrent dans la vue sont générés à la volée, ceux qui sortent sont oubliés, et le monde n'a pas de bord. Trois vues (blocs, biomes, hauteur), un export PNG, et un bouton qui compare trois façons de faire passer les chunks de Go à la page. Ce dernier m'a appris plus sur MyGo que tout le reste.

Côté code, tout tient en quatre fichiers :

  • noise.go : le bruit de Perlin, les octaves, le bruit « ridged », le hash des seeds ;
  • world.go : hauteurs, biomes, arbres, chunks, cache, et World.Stream ;
  • src/render.ts : le rendu isométrique d'un chunk ;
  • src/main.ts : caméra, chargement des chunks, réglages.

Pourquoi pas le hasard pur ?

Premier réflexe pour générer un terrain : tirer une hauteur au hasard pour chaque colonne. Le résultat est inutilisable. Deux colonnes voisines n'ont rien en commun, on obtient un champ de pics. Ce qu'il faut, c'est un hasard continu : imprévisible à grande échelle, mais lisse de près.

La première idée est le bruit de valeur : on ne tire des valeurs qu'aux points d'une grille, et on interpole entre eux. C'est mieux, mais la grille se devine, car tous les sommets et tous les creux tombent pile dessus.

En 1983, après avoir travaillé sur les effets de Tron, Ken Perlin a eu une meilleure idée : ne pas tirer une hauteur à chaque point de la grille, mais une pente. Ce travail lui vaudra un Oscar technique en 1997.

FIG_001 — DU HASARD AU RELIEFinteractif
pointillés : niveau de la mer

Perlin ne tire pas une hauteur mais une pente (en jaune). La courbe passe par 0 à chaque point de grille, avec cette pente, et les bosses se placent entre les points, à des endroits variables : la grille ne se voit presque plus.

Une coupe de terrain en une dimension. Le hasard pur change de valeur à chaque pas ; le bruit de valeur et le bruit de Perlin ne tirent au sort qu'aux points de la grille (les traits verticaux) et interpolent entre eux.

Le bruit de Perlin, pas à pas

En deux dimensions, le principe reste le même. Chaque coin de la grille porte un vecteur gradient, une direction. Pour connaître la valeur du bruit en un point P :

  1. on repère la cellule qui contient P, et ses 4 coins ;
  2. pour chaque coin, on calcule le vecteur qui va du coin à P ;
  3. on fait le produit scalaire entre ce vecteur et le gradient du coin : positif si P est « devant » le gradient, négatif s'il est derrière ;
  4. on mélange les 4 valeurs, d'abord en x puis en y, avec une interpolation adoucie.

Le plus simple, c'est de le faire bouger :

FIG_002 — UNE CELLULE DE PERLINdéplacez P
g₀₀ +0.62g₁₀ +0.62g₀₁ +0.30g₁₁ −0.32P
  1. 1. Décalages

    d = P − coin, avec P = (0.30, 0.62)

    un vecteur par coin, en pointillés

  2. 2. Produits scalaires

    g₀₀ · d = +0.62g₁₀ · d = +0.62g₀₁ · d = +0.30g₁₁ · d = −0.32

    chaque coin « vote » pour P

  3. 3. Courbe fade

    u = fade(0.30) = 0.16v = fade(0.62) = 0.72

    6t⁵ − 15t⁴ + 10t³

  4. 4. Interpolation en x

    haut = lerp(g₀₀, g₁₀, u) = +0.62bas = lerp(g₀₁, g₁₁, u) = +0.20

    entre les coins du haut, puis ceux du bas

  5. 5. Interpolation en y

    lerp(haut, bas, v) = +0.32

    la valeur du bruit en P

Le calcul exact de Noise2 dans noise.go, pour le point P. Le fond est le bruit de la cellule : ambre au-dessus de 0, bleu en dessous. Chaque coin a un gradient tiré de la table de permutation de la seed.

En Go, c'est une vingtaine de lignes :

go
func (p *Perlin) Noise2(x, y float64) float64 {
	fx, fy := math.Floor(x), math.Floor(y)
	xi, yi := int(fx)&255, int(fy)&255
	x, y = x-fx, y-fy // position dans la cellule, entre 0 et 1
	u, v := fade(x), fade(y)

	// Le gradient de chaque coin vient de la table de permutation.
	aa := p.perm[int(p.perm[xi])+yi]
	ab := p.perm[int(p.perm[xi])+yi+1]
	ba := p.perm[int(p.perm[xi+1])+yi]
	bb := p.perm[int(p.perm[xi+1])+yi+1]

	return lerp(
		lerp(grad(aa, x, y), grad(ba, x-1, y), u),
		lerp(grad(ab, x, y-1), grad(bb, x-1, y-1), u),
		v,
	)
}

Trois détails comptent.

La courbe fade. Une interpolation linéaire laisse des cassures visibles aux bords des cellules. Perlin utilise d'abord 3t² − 2t³, puis, dans sa version « améliorée » de 2002, 6t⁵ − 15t⁴ + 10t³. Cette courbe a une dérivée première et une dérivée seconde nulles en 0 et en 1 : les cellules se raccordent sans pli, même dans l'éclairage, qui dépend des pentes.

La table de permutation. Pas de vrai hasard au moment du calcul : un tableau des nombres de 0 à 255, mélangé une fois, sert à associer un gradient à chaque coin. C'est là qu'intervient la seed : elle pilote ce mélange, et deux seeds donnent deux mondes différents mais tout aussi lisses.

Des gradients fixes. La version de 2002 remplace les vecteurs aléatoires par un petit jeu de directions fixes : 12 en 3D, et 8 dans ma version 2D (les axes et les diagonales). Ça suffit visuellement, et ça évite des calculs de racines. Minecraft utilise d'ailleurs une classe qui s'appelle tout simplement ImprovedNoise.

Superposer des octaves

Un seul bruit de Perlin donne de grosses bosses régulières, toutes de la même taille : une colline, une cuvette, une colline. Un vrai paysage a des détails à toutes les échelles : des continents, des collines, des rochers. L'astuce s'appelle fBm (fractional Brownian motion). On additionne plusieurs couches de bruit, appelées octaves, chacune plus fine et moins haute que la précédente :

go
func (p *Perlin) FBM(x, y float64, octaves int, persistence, lacunarity float64) float64 {
	sum, amp, freq, norm := 0.0, 1.0, 1.0, 0.0
	for range octaves {
		sum += amp * p.Noise2(x*freq, y*freq)
		norm += amp
		amp *= persistence  // chaque couche pèse moins…
		freq *= lacunarity  // …et elle est plus fine
	}
	return sum / norm
}
  • La lacunarité multiplie la fréquence d'une octave à l'autre, souvent par 2.
  • La persistance multiplie le poids d'une octave à l'autre, souvent par 0,5.
FIG_003 — LES OCTAVESinteractif
0 ms dans le navigateur

Octaves, une par une (fréquence × 2.0, poids × 0.50)

×1.00

×0.50

×0.25

×0.13

×0.06

Avec une seule octave, on obtient de grosses bosses molles, toutes de la même taille. Avec les réglages de l'app, chaque octave ajoutée apporte des détails deux fois plus fins et deux fois moins hauts : les côtes se découpent, les collines se plissent. Au-delà de 6 ou 7, les nouvelles couches font moins d'un bloc de haut et ne changent plus rien.

Les 512 × 512 blocs autour du spawn de la seed « mygo », calculés dans votre navigateur avec un portage TypeScript du générateur Go (mêmes formules : 2000 colonnes sur 2000 identiques à l'app).
CAPTURE_03 — UNE OCTAVE CONTRE CINQ, MÊME SEED
Terrain généré avec une seule octave

1 octave : des bosses molles

Terrain généré avec cinq octaves

5 octaves et le bruit ridged : un relief crédible

Du bruit au relief

Le bruit donne un nombre entre −1 et 1, et il faut encore en faire une hauteur de colonne. Trois étapes, toutes dans groundHeight :

1. Les continents. Le fBm est mélangé à un bruit quatre fois plus lent, qui soulève ou enfonce des régions entières. Un léger biais (+0,08) donne un peu plus de terre que de mer. Sous 0, c'est l'océan, et sa profondeur suit le bruit.

2. La redistribution. Au-dessus de 0, la hauteur est élevée à une puissance, un exposant de 1,4 par défaut. Comme la valeur est entre 0 et 1, la puissance écrase les basses terres et laisse les hauts presque intacts : des plaines larges et des collines plus marquées. Un piège m'a coûté un moment ici. Mon preset « Montagnes », avec un exposant fort, donnait le terrain le plus plat de tous : une valeur de 0,5 élevée à la puissance 2,2 ne vaut plus que 0,22. Il a fallu étirer le bruit avant de lui appliquer la puissance.

3. Les montagnes. Le fBm fait de bonnes collines, mais jamais de chaînes de montagnes : il est trop rond. La technique classique est le bruit ridged, qui somme 1 − |bruit| au lieu du bruit. Les lignes où le bruit passe par zéro deviennent des crêtes pointues :

go
func (p *Perlin) Ridged(x, y float64, octaves int, persistence, lacunarity float64) float64 {
	sum, amp, freq, norm := 0.0, 1.0, 1.0, 0.0
	for range octaves {
		r := 1 - math.Abs(p.Noise2(x*freq, y*freq)) // 0 devient un sommet
		sum += amp * r * r                          // au carré : crêtes plus fines
		norm += amp
		amp *= persistence
		freq *= lacunarity
	}
	return math.Max(0, (sum/norm-0.45)/0.55)
}

Appliqué partout, ça donnerait un monde entièrement montagneux. Un troisième bruit, très lent, sert donc de masque : les chaînes n'apparaissent que là où il dépasse un seuil, et seulement à l'intérieur des terres.

CAPTURE_04 — LES MONTAGNES ET LA JUNGLE
Massif rocheux et enneigé en vue isométrique

Bruit ridged sous un masque : crêtes, neige, vallées

Jungle dense au bord de la mer

Jungle, plage et savane en (48, −302)

Les biomes

Le climat vient de deux autres bruits, très lents (cinq fois l'échelle du relief) : une température et une humidité. Une colonne chaude et sèche donne un désert, chaude et humide une jungle, froide et humide une taïga, et ainsi de suite. La hauteur l'emporte sur le climat : au-dessus d'un certain seuil, c'est de la roche, puis de la neige.

Ce système produisait des frontières de biomes trop parfaites, de longues courbes lisses, parce que le bruit lent est très régulier de près. La correction : un bruit rapide et faible, ajouté à la température et retiré de l'humidité, qui effiloche les bordures sans les déplacer. L'outil TestDumpMap du projet exporte 1024 × 1024 blocs vus de dessus. C'est avec lui que j'ai réglé tout ça, bien plus vite qu'en me promenant dans l'app :

CAPTURE_05 — 1024 × 1024 BLOCS VUS DE DESSUS
Carte vue de dessus de 1024 sur 1024 blocs, avec océans, plaines, désert, jungle et massifs enneigés
go test -run TestDumpMap, réglage Montagnes. On y voit les massifs enneigés (le bruit ridged sous son masque), un désert en bas à gauche, une jungle en haut au centre, et les frontières légèrement effilochées.

Un monde infini, chunk par chunk

Tout repose sur une propriété : la hauteur d'une colonne ne dépend que de la seed et de ses coordonnées. Pas de l'ordre dans lequel on explore, pas de ce qui a été généré avant. C'est pour ça que deux joueurs avec la même seed voient le même monde. On peut donc découper le monde en chunks de 16 × 16 colonnes, comme Minecraft, et calculer n'importe lequel à n'importe quel moment, de façon indépendante :

  • on n'a besoin de garder en mémoire que les chunks autour du joueur ;
  • un chunk oublié puis redemandé revient identique, au bloc près ;
  • les chunks se calculent en parallèle, sans se coordonner.
FIG_004 — UN MONDE INFINI, CHUNK PAR CHUNKglissez la carte

—

Chunk survolé

0

Chunks générés

0

En mémoire

8, 8

Position

Videz le cache : la carte se reconstruit à l'identique, car rien n'est mémorisé d'un chunk à l'autre. Changez une lettre de la seed : tout le monde change. Avec + arbres, chaque point est le seul candidat de sa cellule de 5 × 5 blocs ; un chunk retrouve ainsi les arbres de ses voisins dont les feuilles débordent chez lui, sans jamais les consulter.

Vue du dessus, 2 pixels par bloc. Chaque carré est un chunk de 16 × 16 colonnes, calculé à la demande par une fonction de (seed, cx, cz) seulement. Le flash jaune marque un chunk qui vient d'être généré.

Le seul point délicat, ce sont les arbres. Un arbre a une couronne de 5 × 5 blocs : planté au bord d'un chunk, ses feuilles débordent sur le voisin. Si chaque chunk décidait de ses arbres tout seul, on obtiendrait des arbres coupés net à la frontière. La solution est une grille jitterée. Le monde est découpé en cellules de 5 × 5, et chaque cellule a au plus un arbre, placé à un endroit tiré de son hash. Pour se dessiner, un chunk examine les cellules qui le touchent, débordement compris, et retrouve exactement les mêmes arbres que ses voisins, sans jamais les consulter. Un test le vérifie : des feuilles doivent apparaître sur les bordures de chunks.

Générer sur tous les cœurs

Puisque les chunks sont indépendants, Go peut les répartir sur tous les cœurs. World.Stream lance un worker par cœur, et envoie chaque chunk à la page dès qu'il est prêt, en commençant par le centre de la vue. Il passe pour cela par un Channel MyGo, le mécanisme de streaming du framework :

go
// Stream génère les (2·radius)² chunks autour de (cx, cz) sur tous les
// cœurs et envoie chacun dès qu'il est prêt, du centre vers l'extérieur.
func (w *World) Stream(ctx context.Context, p Params, cx, cz, radius int,
	skip []string, out *mygo.Channel[Chunk]) (StreamStats, error) {
	// … un worker par cœur, qui lit les coordonnées dans `jobs` …
	for c := range results {
		if err := out.Send(c); err != nil {
			return stats, err // la page a fermé le canal : ctx est annulé
		}
	}
	// …
}

Côté page, le client TypeScript est généré par MyGo à partir de cette signature. Je n'ai écrit que la boucle de réception :

ts
const ch = new Channel<Chunk>();
ch.onmessage = (c) => addChunk(c.x, c.z, base64ToBytes(c.data));
const stats = await World.stream(params, cx, cz, radius, [...chunks.keys()], ch);

Quand le joueur repart avant la fin, la page ferme le canal. Côté Go, Send échoue et le contexte est annulé, ce qui arrête les workers : aucun chunk inutile n'est calculé.

Infini… ou presque

Le bruit de Perlin classique n'est pas infini. Sa table de permutation fait 256 entrées, donc le bruit se répète toutes les 256 unités. Avec une échelle de 96 blocs par unité, le relief de base revient à l'identique tous les 24 576 blocs. La couche « continents », quatre fois plus lente, ne revient qu'au bout de 98 304 blocs. Je suis allé vérifier :

CAPTURE_06 — LE MÊME ENDROIT, À 98 304 BLOCS DE DISTANCE
Relief en niveaux de gris autour de l'origine

(8, 8), vue hauteur

Le même relief, 98 304 blocs plus loin

(98 312, 8), vue hauteur : même colline

Le même relief en blocs, avec un désert

(98 312, 8), vue blocs : désert et cactus

Le relief de base est identique, mais pas tout : les montagnes et le climat utilisent d'autres échelles, dont les périodes ne tombent pas au même endroit. La colline est la même, et c'est un désert.

C'est ma limite préférée du projet. Les couches de bruit à des échelles différentes ont des périodes qui ne coïncident pas, si bien que le monde complet met bien plus longtemps à se répéter que chacune de ses couches. Minecraft règle le problème autrement : chaque octave y reçoit son propre décalage aléatoire. Et la vraie limite de son monde infini est venue d'ailleurs, de la précision des nombres à virgule. Avant la Beta 1.8, au-delà de 12 550 821 blocs, les calculs du bruit débordaient et le terrain se transformait en murs déchiquetés : les fameuses Far Lands.

Le rendu isométrique

La page reçoit des octets : 8 par colonne (hauteur, bloc du dessus, biome, puis deux « étages » optionnels pour les troncs, cactus et feuillages). Elle doit en faire une image. En isométrie, chaque colonne se dessine avec trois formes : un losange pour le dessus, et deux parallélogrammes pour les faces tournées vers nous.

Il n'y a ni 3D ni tampon de profondeur, juste l'algorithme du peintre : on dessine du fond vers l'avant, et ce qui est peint après recouvre ce qui est peint avant.

FIG_005 — L'ALGORITHME DU PEINTREinteractif

On dessine les rangées du fond vers l'avant : une colonne proche recouvre naturellement la falaise qui est derrière elle. Pas de tampon de profondeur, pas de tri : l'ordre des boucles suffit. Chaque colonne est ici dessinée jusqu'en bas, et pourtant on ne voit que ce qui doit l'être.

Un vrai chunk de la seed « mygo », en (−128, 160). Chaque colonne est un losange (le dessus) et deux faces ; ce qui est dessiné après recouvre ce qui est dessiné avant.

Deux optimisations rendent le tout rapide :

  • Un sprite par chunk. Chaque chunk est dessiné une seule fois dans son propre canvas. La vue ne fait ensuite que coller ces images dans le bon ordre, une rangée de chunks après l'autre. En se déplaçant, seuls les chunks qui entrent dans la vue sont dessinés, et jamais plus de 8 ms par image : s'il en reste, ils attendent l'image suivante.
  • Des faces coupées au plus court. La face d'une colonne ne descend que jusqu'à la hauteur du voisin de devant ; le reste serait caché. Seules les colonnes du bord d'un chunk descendent jusqu'en bas. Elles forment les flancs du diorama, en couches d'herbe, de terre et de pierre, et le chunk suivant les recouvre quand il existe.

Et MyGo, alors ?

C'était le vrai sujet du test. MyGo est un framework jeune (v0.2) qui veut être un Electron en Go : la fenêtre et le système en Go, l'interface dans la webview du système (WebView2 sous Windows), et entre les deux, des appels typés.

Ce qui est excellent

Les bindings. On écrit une méthode Go, mygo dev génère la fonction TypeScript correspondante, avec ses types et sa documentation. Une struct Go Params devient une interface TS, un *mygo.Channel[Chunk] devient un Channel<Chunk>. Changer un paramètre côté Go casse la compilation côté front, ce qui est exactement ce qu'on veut.

La boucle de développement. Une modification Go déclenche une recompilation, la régénération des bindings et une relance de l'app, le tout en 1 à 1,5 s. Pour un générateur procédural, qu'on règle à coups de dizaines d'essais, c'est ce qui rend le travail agréable.

Le résultat. Un exécutable de 8,6 Mo et un installeur de 2,9 Mo, produits en 7 secondes une fois le cache de compilation chaud. Sur mon test précédent, une app MyGo affichait sa fenêtre en un dixième de seconde et ne consommait rien au repos.

Les trois façons de faire passer un chunk

MyGo propose deux chemins pour envoyer des données à la page : les Channels (des messages JSON) et les protocoles personnalisés (un http.Handler Go que la page interroge avec fetch, en binaire). Sur le papier, le binaire devrait gagner haut la main. J'ai mesuré :

FIG_006 — TROIS FAÇONS D'AMENER LES CHUNKSmesuré

Channel MyGo · JSON, []byte en base64, 1 message par chunk

9.9 ms

384 Ko transférés

chunk:// par chunk · binaire, 144 requêtes (8 en parallèle)

80.8 ms

288 Ko transférés

chunk:// en un lot · binaire, 1 requête

4.0 ms

289 Ko transférés

Le base64 du Channel coûte un tiers de volume en plus, et ça ne se voit presque pas. Ce qui coûte cher, c'est le nombre de requêtes : chaque aller-retour sur un protocole personnalisé passe par WebView2 et prend environ 0,5 ms, quelle que soit sa taille. À froid, le lot est un peu plus lent que le Channel parce que mon handler génère ses chunks sur un seul cœur, là où World.Stream les répartit sur 16.

Temps entre la demande de la page et la réception du dernier chunk, pour les 144 chunks d'une vue de 12 × 12. Mesures du bouton « Comparer les transports » de l'app, en build de production.

Le Channel gagne largement contre une requête par chunk, et le lot binaire est le plus rapide une fois le cache chaud. Ce que j'en retiens : Channel pour les flux de petits messages, protocole pour les gros blobs, et surtout pas une requête par petit objet.

Le piège du CORS

Le premier essai du protocole chunk:// s'est soldé par un laconique « Failed to fetch ». Sous Windows, WebView2 sert les protocoles personnalisés sur http://chunk.localhost, une origine différente de la page, qui vit sur http://mygo.localhost (ou sur le serveur Vite en développement). Sans en-tête Access-Control-Allow-Origin, le navigateur refuse la réponse. La doc présente pourtant fetch sur ces protocoles comme allant de soi. Sous Linux, MyGo déclare ses protocoles « CORS-enabled » et le problème n'existe pas ; sous Windows, il faut ajouter l'en-tête soi-même. J'ai vérifié que c'était bien la cause avec un build sans en-tête, qui échoue aussi en production.

FIG_007 — MYGO, LE BILANcliquez pour déplier

Contribuer en chemin

En testant MyGo sur des projets précédents, j'étais tombé sur deux défauts de son backend Windows, que j'ai corrigés directement dans le framework. Chaque fois avec la même méthode : un test automatique qui reproduit le bug, qui échoue avant le correctif, qui passe après, et toute la suite de tests Windows au vert.

  • Le plein écran gardait la barre de menu : PR #29, fusionnée le jour même et publiée dans la v0.2.3, celle qu'utilise ChunkGo.
  • Les menus restaient clairs dans une app sombre : PR #30, en revue. Le test lit la vraie couleur des pixels de la barre de menu pendant qu'on bascule le thème.

Le piège du CORS ferait un bon troisième candidat : MyGo pourrait autoriser automatiquement l'origine de l'app sur ses propres protocoles, comme il le fait déjà sous Linux.