GPU Fluid Simulation - Compute Creeper


Inspiration and Timeline

Inspired by the fluid dynamics of Creeper World 3, I set out to push the boundaries of fluid simulation scale and efficiency. Creeper World games revolve around a unique strategy concept where players combat an ever-expanding, fluid-like entity called "Creeper", which flows dynamically across the map, engulfing terrain and structures. My goal was to replicate the core mechanics of the Creeper World series while dramatically expanding map size and simulation capacity. I beagn working on recreating these mechanics in my own proof of concept game, using the GPU and compute shaders, aptly named Compute Creeper in November of 2023 and culminated in January 2024.

Screenshot from Creeper World 3 showing the largest possible map size in the game of 250x250 cells.

Goals of Project

During the development of this project there were a few core mechanics from Creeper World 3 I wanted to implement in my prototype:

  • Fluid simulation of a Creeper-like material
  • Generation of 2D terrain using perlin noise
  • Map customization and creation
  • Basic unit AI and interactions

Each of these mechanics pose their own challenges especially considering I was also aiming for an increased max map size of 1000x1000 cells (a total cell count of 1 million cells!).

Initial Algorithm of Fluid Simulation

Initally during development of this project I had a method that used for loops to loop over and update the contents of the each cell, which looked like this (Written in C#):


    //Update each cell in the map grid
    private void UpdateCells()
    {
        for (int y = 0; y < mapSize.y; y++)
        {
            for (int x = 0; x < mapSize.x; x++)
            {
                UpdateCell(x, y);
            }
        }
    }

    //Update a single cell
    private void UpdateCell(int x, int y)
    {
        CellData cell = grid[x, y];

        //Check top neighbor
        if(y < mapSize.y - 1) // Check if in bounds of map
        {
            // Get cell above this one
            CellData topCell = grid[x, y + 1];

            // Check if cell has more fluid
            if(topCell.fluidAmount > cell.fluidAmount)
            {
                // Change the amount of fluid in both cells based on some flow rate
                float change = flowRate * (topCell.fluidAmount - cell.fluidAmount);
                cell.fluidAmount += change;
                topCell.fluidAmount -= change;
            }
        }

        //Check other neighbors and do the same
    }
                    

This solution was fairly error free, easy to debug, and relatively easy to understand; however, it was clear that this method was increasingly expensive as the map size grew. Using this method I could easily simulate a 100x100 map with a frame rate of around 60 but to reach my goal of 1000x1000 cells I was going to have to do some optimization.

My first thought was to use multithreading and create a series of threads that would each work on and update a subset of cells of the map. This definitely helped, but lead to a different problem, each thread was trying to access the same data when updating neighboring cells. I needed a way for each cell to update without modifying other cells, but how could this be achieved?

Cellular Automata

I began researching more about this idea of self updating cells and ran across the concept of Cellular Automata and Conway's Game of Life and instantly knew that this was something that I could apply to my algorithm to solve my problem. In Conway's simulation each cell can be either alive (black) or dead (white), and its state updates based on 3 simple rules:

  1. Survival: A live cell with 2 or 3 live neighbors stays alive.
  2. Birth: A dead cell with exactly 3 live neighbors becomes alive
  3. Death: In all other cases, cells die or remain dead due to underpopulation or overpopulation.

Simulation of "creatures" in Conway's Game of Life eating and reproducing.

Using this same principles I was able to come up with my own simple rules for my fluid simulation:

  1. Flow In: A cell will increase its fluid amount for each neighbor that has more fluid than this cell
  2. Flow Out: A cell will decrease its fluid amount for each neighbor that has less fluid than this cell

This concept was surprising simple but its implementation was tricky, especially when trying to maintain a consistent amount of fluid within the simulation. I ran into problems of how to calcualte how much fluid should flow into and out of each cell. For instance given a 3x3 grid of cells with the starting configuration shown below let's try to calculate the change in fluid for the cell highlighted in yellow:

0.00.00.0
0.04.00.0
0.00.00.0

We know that the highlighted cell satisfies Rule 1: "A cell will increase its fluid amount for each neighbor that has more fluid than this cell" because the center cell has 4.0 total fluid whereas the highlighted cell has 0.0. Therefore this cell should increase its fluid amount this simulation step. Lets call this increase in fluid this step: flowInAmount. Then flowInAmount can be calculated using the following code snippet:


    // Percentage of fluid that should flow to neighboring cells each step
    float flowRate = 0.1f;
    // Amount that flows into this cell this step
    float flowInAmount = 0.0f;

    // Looping over each of the eight neighboring cells
    for(int i = 0; i < 8; i++)
    {
        // If neighbor has more fluid increase the flowInAmount
        if(neighbor[i].fluidAmount > cell.fluidAmount)
            flowInAmount += flowRate * (neighbor[i].fluidAmount - cell.fluidAmount);
    }
                    

So what's the issue with this calculation? Nothing it works, it works fine when flowRate remains under 0.125 (1/8) however if the flowRate becomes higher than this threshold cells may increase more than they should and inadvertently create more fluid than was initially held within the map. Consider the following scenarios:

flowRate = 0.1

Step 0

0.00.00.0
0.04.00.0
0.00.00.0

Step 1

0.40.40.4
0.40.80.4
0.40.40.4

Step 2

0.440.440.44
0.440.480.44
0.440.440.44

After all steps have completed the sum of the fluid in all cells is: 4.0

flowRate = 0.2

Step 0

0.00.00.0
0.04.00.0
0.00.00.0

Step 1

0.80.80.8
0.8-2.40.8
0.80.80.8

After only one simulation the center cell has become negative so clearly there is an issue with this apporach.

Why the GPU?