Magic Bookof Algorithms EN

Prozedurale ErzeugungPRC-02

Reaction-Diffusion

Zwei Stoffe, zwei Regeln, zwei Zahlen — und daraus Korallen, Streifen und Leopardenflecken.

Laufzeit
O(Zellen · Schritte)
Modell
Gray-Scott
Stellschrauben
feed und kill
Idee von
Alan Turing, 1952

01Worum es geht

1952 hat Alan Turing eine Arbeit veröffentlicht, die mit Computern nichts zu tun hatte: The Chemical Basis of Morphogenesis. Die Frage darin war, wie aus einem gleichförmigen Klumpen Zellen ein Leopard werden kann — woher das Muster kommt, wenn am Anfang überall dasselbe ist.

Seine Antwort: zwei Stoffe, die sich unterschiedlich schnell ausbreiten und miteinander reagieren, erzeugen von selbst Flecken und Streifen. Aus einem gleichmäßigen Zustand entsteht ein ungleichmäßiger, ohne dass irgendjemand das Muster vorgibt. Das ist kontraintuitiv genug, dass es dreißig Jahre gedauert hat, bis Chemiker es im Reagenzglas zeigen konnten.

Hier steht das Gray-Scott-Modell, die bekannteste Variante. Es besteht aus zwei Zeilen Rechnung — und liefert Korallen, Zebrastreifen, Hirnwindungen und Fingerabdrücke, je nachdem, an welchen zwei Zahlen man dreht.

feed 0,0545 · kill 0,0620
feed 0,0545 · kill 0,0620
feed 0,0580 · kill 0,0650
feed 0,0580 · kill 0,0650
feed 0,0300 · kill 0,0620
feed 0,0300 · kill 0,0620
Dreimal derselbe Code, dieselben Starttropfen, 6000 Schritte. Nur zwei Zahlen sind anders.

02So funktioniert es

Die zwei Stoffe

A wird von außen nachgefüllt. B frisst A und wird dabei selbst zu mehr B. Und B wird laufend abgeführt. Beide breiten sich aus, aber A doppelt so schnell wie B — dieser Unterschied ist die eigentliche Ursache des Musters.

A' = A + dA · streuung(A) − A·B·B + feed · (1 − A)
B' = B + dB · streuung(B) + A·B·B − (kill + feed) · B

Das A·B·B ist die Reaktion: Es braucht zwei B, um ein A umzuwandeln — daher das Quadrat. Genau diese Rückkopplung macht das System instabil: Wo schon etwas B ist, entsteht schneller mehr B.

Die Streuung

streuung ist der Laplace-Operator — derselbe, der auf der Kantenerkennung Kanten findet. Hier hat er eine anschaulichere Bedeutung: Wie viel fließt von den Nachbarn herein? Er ist der Durchschnitt der Umgebung minus dem eigenen Wert. Diagonale Nachbarn zählen weniger, weil sie weiter weg liegen.

Die Ränder sind zusammengeklebt: Was rechts hinausläuft, kommt links wieder herein. Deshalb hat das Muster keinen Rand.

Feed und Kill

Alles hängt an zwei Zahlen, und beide liegen zwischen 0,02 und 0,08:

  • feed — wie viel A nachkommt
  • kill — wie viel B abgeführt wird

Ein Hundertstel in die eine Richtung, und aus Streifen werden Punkte. Ein Hundertstel in die andere, und alles stirbt ab oder wächst zu. Die Landkarte dieser beiden Werte ist selbst ein bekanntes Bild in der Literatur; die drei Rezepte im Beispiel sind Punkte darauf.

Der erste Tropfen

Ohne Störung passiert nie etwas: A überall, B nirgends — dieser Zustand bleibt für immer. Deshalb werden ein paar Tropfen B hineingesetzt. Von dort aus wächst das Muster nach außen, und wo zwei Wachstumsfronten sich treffen, entstehen genau die Verzweigungen, die das Ganze wie etwas Gewachsenes aussehen lassen.

03Ausprobieren

04Implementierung

MagicBook.Algorithms/Procedural/ReactionDiffusion.csC#
using MagicBook.Algorithms.Randomness;

// Alan Turing hat 1952 gefragt, wie aus einem gleichförmigen Klumpen Zellen ein
// Leopard wird, und mit zwei Stoffen geantwortet, die sich ausbreiten und miteinander
// reagieren. Hier steht Gray-Scott: A kommt von außen nach, B frisst A und wird dabei
// zu mehr B, und B wird abgeführt. Beide streuen, A aber doppelt so schnell wie B.
//
// Alles hängt an zwei Zahlen. feed ist, wie viel A nachkommt, kill, wie viel B
// abgeführt wird. Ein Hundertstel in eine der beiden Richtungen macht aus Streifen
// Punkte oder lässt das ganze Muster absterben.
public static class ReactionDiffusion
{
    private const float SpreadA = 1.0f;
    private const float SpreadB = 0.5f;

    public static float[] Simulate(int width, int height, int steps, float feed, float kill, ulong seed)
    {
        var a = new float[width * height];
        var b = new float[width * height];

        Array.Fill(a, 1f);
        Splash(b, width, height, seed);

        var nextA = new float[a.Length];
        var nextB = new float[b.Length];

        for (var step = 0; step < steps; step++)
        {
            for (var cell = 0; cell < a.Length; cell++)
            {
                // Wie viel A und B hier zusammentreffen. Es braucht zwei B, um ein A in ein
                // weiteres B zu verwandeln, daher das Quadrat.
                var reaction = a[cell] * b[cell] * b[cell];

                nextA[cell] = a[cell] + SpreadA * Spread(a, width, height, cell) - reaction + feed * (1 - a[cell]);
                nextB[cell] = b[cell] + SpreadB * Spread(b, width, height, cell) + reaction - (kill + feed) * b[cell];

                nextA[cell] = Math.Clamp(nextA[cell], 0f, 1f);
                nextB[cell] = Math.Clamp(nextB[cell], 0f, 1f);
            }

            (a, nextA) = (nextA, a);
            (b, nextB) = (nextB, b);
        }

        return b;
    }

    // Wie viel von den Nachbarn hereinfließt: der Durchschnitt ringsum minus dem
    // eigenen Wert. Diagonale Nachbarn zählen weniger, weil sie weiter weg sind.
    // Das ist der Laplace-Operator, derselbe, den die Kantenfilter verwenden.
    private static float Spread(float[] values, int width, int height, int cell)
    {
        var x = cell % width;
        var y = cell / width;
        var sum = -values[cell];

        for (var dy = -1; dy <= 1; dy++)
        {
            for (var dx = -1; dx <= 1; dx++)
            {
                if (dx == 0 && dy == 0)
                    continue;

                // Die Ränder sind zusammengeklebt, deshalb hat das Muster keinen Rand.
                var neighbour = ((y + dy + height) % height) * width + (x + dx + width) % width;

                sum += values[neighbour] * (dx == 0 || dy == 0 ? 0.2f : 0.05f);
            }
        }

        return sum;
    }

    // Ohne Störung passiert nie etwas: A überall, B nirgends, und dieser Zustand
    // bleibt, wie er ist. Also werden ein paar Tropfen B hineingesetzt.
    private static void Splash(float[] b, int width, int height, ulong seed)
    {
        var random = new Pcg(seed);

        for (var drop = 0; drop < 14; drop++)
        {
            var centreX = random.Next(width);
            var centreY = random.Next(height);

            for (var y = -4; y <= 4; y++)
            {
                for (var x = -4; x <= 4; x++)
                    b[((centreY + y + height) % height) * width + (centreX + x + width) % width] = 1f;
            }
        }
    }
}

05Drei Rezepte

MagicBook.Console/Examples/ReactionDiffusionExample.csC#
const int width = 200;
const int height = 150;
const int steps = 6000;

// Dieselben zwei Regeln, dieselben Starttropfen, nur feed und kill sind anders -
// und heraus kommt jedes Mal etwas völlig anderes.
(string Name, float Feed, float Kill)[] recipes =
[
    ("coral", 0.0545f, 0.0620f),
    ("stripes", 0.0580f, 0.0650f),
    ("spots", 0.0300f, 0.0620f),
];

Console.WriteLine("pattern   feed    kill    covered by B");

foreach (var (name, feed, kill) in recipes)
{
    var b = ReactionDiffusion.Simulate(width, height, steps, feed, kill, seed: 20240921);

    // B kommt nie in die Nähe von 1, deshalb werden die Werte für das Bild
    // auseinandergezogen, bis die stärkste Stelle weiß ist.
    var strongest = b.Max();
    var shown = new float[b.Length];

    for (var i = 0; i < b.Length; i++)
        shown[i] = b[i] / strongest;

    Channels.ToImage(shown, width, height).Save(Path.Combine(folder, $"reaction-{name}.png"));

    Console.WriteLine($"{name,-8}  {feed:0.0000}  {kill:0.0000}  " +
        $"{b.Count(value => value > 0.25f) * 100.0 / b.Length,10:0.0} %");
}
Ausgabe der Konsole
pattern   feed    kill    covered by B
coral     0.0545  0.0620        44.9 %
stripes   0.0580  0.0650        17.0 %
spots     0.0300  0.0620        17.5 %

06Gut zu wissen

  • Das Verfahren ist eine explizite Simulation: kleine Schritte, viele davon. 6000 Schritte auf 200 × 150 Feldern sind 180 Millionen Zellenrechnungen — und trotzdem in Sekunden fertig, weil jede davon nur ein paar Multiplikationen ist. Zu große Schritte lassen die Rechnung explodieren; das ist der Grund für die vielen kleinen.
  • Jede Zelle hängt nur von ihren acht Nachbarn ab. Damit ist das Verfahren perfekt parallel — auf der Grafikkarte läuft es in Echtzeit, und genau so entstehen die Live-Demos, die man im Netz findet.
  • Turing hat das Muster vorhergesagt, ohne es je zu sehen. Erst die Belousov-Zhabotinsky-Reaktion hat gezeigt, dass Chemie das wirklich tut, und erst in den Neunzigern haben Biologen die Streifen bestimmter Fische tatsächlich auf solche Systeme zurückführen können.
  • Verwandt in der Wirkung, aber viel einfacher in der Regel: Conways Spiel des Lebens und zelluläre Automaten allgemein. Der Unterschied ist, dass hier mit Fließkommazahlen gerechnet wird statt mit an/aus — das Muster ist deshalb weich statt pixelig.