Zum Inhalt springen
Michael Malura
570 Github Stars 5810 npm Downloads 53 Projekte

Pixel-Wasser

Ich wollte Wasser, das in ein Becken läuft und sich flach hinlegt, ohne dass ich Strömungen rechne. Falling Sand ist dafür die kleinste Lösung, die ich kenne: ein Gitter, in jeder Zelle steckt Sand, Wasser, Stein oder nichts, und jede Zelle schaut nur auf ihre Nachbarn.

Beim Start läuft oben links schon ein Wasserhahn. Das Wasser trifft ein Regal, fällt am rechten Ende in ein Becken, und über der Insel im Becken ist Sand heruntergekommen, der jetzt als Haufen im Wasser liegt. Mit gedrückter Maustaste malst du Wasser, Sand oder Stein, der Radierer löscht. Die Pinselgröße steht auf 1, 3 oder 6 und geht auch mit dem Mausrad. "Tap" dreht den Hahn zu, "Pause" friert alles ein, "Clear" leert das Gitter, "Basin" baut die Startszene neu auf, und "Flow" stellt ein, wie weit Wasser pro Schritt seitlich laufen darf. Unter dem Gitter stehen die Zellenzahlen und die Zeit pro Schritt.

Gitter und Textur

Das Gitter hat 240 mal 104 Zellen, jede ist 4 mal 4 Pixel groß. Jede Zelle ist ein Pixel einer einzigen Textur (SDL_PIXELFORMAT_RGBA32, SDL_UpdateTexture), die SDL mit Nearest-Filter auf das Vierfache zieht. Leere Zellen haben Alpha 0, das Raster der Seite scheint also durch.

Gerechnet wird in festen Schritten, 60 pro Sekunde, mit einem Akkumulator. Wie das geht, steht in Delta Time und Fester Zeitschritt, hier ist es dieselbe Schleife.

Ein Schritt und die Reihenfolge

Jedes Teilchen darf pro Schritt genau einmal bewegt werden. Das klingt selbstverständlich und ist die Stelle, an der man sich am leichtesten verläuft:

++tick_;
for (int y = h_ - 1; y >= 0; --y) {
    const bool leftToRight = ((tick_ + static_cast<std::uint32_t>(y)) & 1U) == 0;
    for (int i = 0; i < w_; ++i) {
        const int x = leftToRight ? i : w_ - 1 - i;
        const Cell& c = cells_[index(x, y)];
        if (c.movedAt == tick_) continue;
        if (c.kind == Kind::Sand) {
            stepSand(x, y);
        } else if (c.kind == Kind::Water) {
            stepWater(x, y);
        }
    }
}

Die Zeilen laufen von unten nach oben. Wer von oben anfängt, schiebt einen Tropfen von Zeile 5 nach 6, erreicht dann Zeile 6 und schiebt ihn weiter. Am Ende des Schritts liegt er am Boden. Von unten nach oben landet ein fallendes Teilchen in einer Zeile, die schon abgearbeitet ist. Ich habe die Schleife zum Test umgedreht und den Stempel entfernt: fallsOneRowPerStep schlägt sofort an.

Der Stempel movedAt deckt die Seitwärtsbewegung ab. Dabei bleibt das Teilchen in der Zeile, die gerade gescannt wird, und könnte weiter vorn noch einmal drankommen. Wer bewegt wurde, trägt den aktuellen Tick und wird übersprungen. Der leeren Zelle, die zurückbleibt, gebe ich keinen Stempel. Sonst würde sie das Teilchen direkt darüber blockieren und eine fallende Säule könnte nicht mehr im Gänsemarsch nachrücken. aFallingColumnMovesAsOneTrain prüft das mit drei Wasserzellen übereinander.

Links und rechts

Fällt der Sand diagonal, muss irgendwer entscheiden, ob links oder rechts zuerst probiert wird. Immer links zuerst heißt: Die Haufen kippen nach links. Ich werfe pro Zelle und Schritt eine Münze, abgeleitet aus einem Hash von Position und Tick:

int coin(int x, int y) const { return (detail::hash(x, y, tick_) & 1U) != 0 ? 1 : -1; }

Dazu wechselt die Scanrichtung in jeder Zeile und jedem Schritt. Ein Zufallsgenerator mit Zustand fehlt, damit bleibt die Welt deterministisch: Zwei Welten mit demselben Anfang sind nach 500 Schritten Zelle für Zelle gleich (sameStartSameResult). Der Drift-Test lässt 200 Sandzellen als Säule bei x = 49 bis 52 fallen. Der Haufen hat danach seinen Schwerpunkt bei 50,48 und reicht von Spalte 36 bis 65.

Wasser läuft weiter als eine Zelle

Wasser fällt, sonst fällt es diagonal, und wenn beides blockiert ist, läuft es zur Seite. Mit einer Zelle pro Schritt legt sich ein Wasserhaufen quälend langsam hin. Deshalb schaut ein Teilchen bis zu spread Zellen weit:

int probe(int x, int y, int dir, bool& drop) const {
    int target = x;
    drop = false;
    for (int i = 1; i <= spread; ++i) {
        const int nx = x + dir * i;
        if (!isEmpty(nx, y)) break;
        target = nx;
        if (isEmpty(nx, y + 1)) {
            drop = true;
            break;
        }
    }
    return target;
}

Es geht bis zur weitesten freien Zelle, hält aber an, sobald unter einer Zelle Luft ist. So läuft Wasser über eine Kante, statt an ihr vorbeizuschießen. Der Test baut ein 12 Zellen breites Becken und stapelt links 24 Wasserzellen hinein. Mit Reichweite 4 ist der Pegel nach 15 Schritten überall zwei Zellen hoch, mit Reichweite 1 nach 169. Der Chip "Flow" schaltet zwischen 1, 4 und 8 um. Bei 8 sieht der Strahl am Rand des Regals fransig aus, 4 ist mein Kompromiss.

Geht die Zellenzahl nicht in volle Zeilen auf, wandert der Überschuss als einzelne Zellen an der Oberfläche hin und her. Im Teich sieht man das als leises Zucken.

Sand sinkt

Sand tauscht mit Wasser den Platz, gerade und diagonal, damit ein Haufen unter Wasser seine Böschung bekommt:

bool sandCanEnter(int x, int y) const {
    const Kind k = kind(x, y);
    return k == Kind::Empty || (k == Kind::Water && cell(x, y).movedAt != tick_);
}

Wasser, das in diesem Schritt schon bewegt wurde, bleibt tabu, sonst käme es zweimal dran. Der Test setzt einen Sandblock über einen Teich und prüft nach 400 Schritten, dass in jeder Spalte alles Wasser über dem Sand liegt.

Nichts geht verloren

Der Test, der mir am meisten Ruhe gibt: eine verstreute Welt aus Stein, 400 Wasser- und 300 Sandzellen, 1000 Schritte, alle 25 wird gezählt.

const Counts before = w.counts();
for (int round = 0; round < 40; ++round) {
    steps(w, 25);  // 1000 steps in total
    const Counts now = w.counts();
    assert(now.water == before.water);
    assert(now.sand == before.sand);
    assert(now.wall == before.wall);
    assert(now.empty == before.empty);
}

Bewegt wird ausschließlich per std::swap, ein Teilchen kann dabei weder entstehen noch verschwinden. Dazu kommt ein Randtest: Außerhalb des Gitters gilt jede Zelle als Stein (kind() liefert dort Kind::Wall), die Logik prüft also nirgends Grenzen.

Farben

Die Farbstreuung hängt am Teilchen. Jede Zelle bekommt beim Anlegen einen festen Zufallswert (tint), der beim Tauschen mitwandert. Hinge die Streuung an der Position, würde fließendes Wasser flackern. Dazu wird Wasser mit der Tiefe dunkler (gezählt wird das zusammenhängende Wasser darüber) und die oberste Zelle unter Luft heller.

Was ein Schritt kostet, steht unten rechts. Im Headless-Chromium lag er mit 4000 bis 5400 Wasserzellen bei 180 bis 250 Mikrosekunden.

Den Code findest du im Repo unter pixel-water. Alle Demos der Serie sammelt das Projekt Game Dev Snippets.

18.09.2026 aktualisiert 06.11.2026
Teilen X Reddit Hacker News LinkedIn E-Mail
Neue Beiträge abonnieren RSS JSON