Fluid Simulation using Cellular Automata

Summary

This cellular automata system is divided into 3 systems. Cells, chunks and a manager. Cells have their own properties, like how they move, heat up and how strong a material is. These cells can interact with each other like combust, corrode sink in another and transition to a gas state.

Cell chunks are 32×32 cells large. These chunks hold their cells in an array and make sure cells move correctly between other chunks. When all cells are updated a bake call is made and the 32×32 array is translated to a 32×32 sprite where a position is colored with the render color of a cell on that position.

The cell manager makes sure all the chunks are looped through, and it calls the bake call at the end of the loop. This application runs on a separate loop. This is because the cells would move too fast when running on the conventional update loop, which would make the application less heavy.

Introduction

In this document you learn the whole life cycle of my Cellular Automata Fluid Simulation. You can play with the end result by clicking on the link below:

Cellular Automata Lab by Cody-Bolleboom

I wanted to explore ways you could create the building blocks for world generation. I chose for cellular automata, because it is pretty lightweight, you can do a lot of optimization, it is easily scalable, and you can create some cool effects.

Before we start, I want to clarify that when I use the word cell I mean a tiny block with its own properties (a yellow block with falling sand properties). The creators of Noita used actual pixels in their old concept [1].

Cell System

To keep track of all the cells we need a cell manager and cell chunks. These will be explained in depth in the following chapters.

The whole cell system runs in a separate loop, where you can easily change the update-loop speed. This way, it can be easier to debug and the simulation can be changed so it looks more natural. Also if we used the normal update the cells would’ve moved too fast to see. The cell manager creates this update loop and calls the cell chunks from bottom left to top right.

The whole life cycle is as follows, which will be explained in the coming chapters:

A thing to keep in mind is that this system uses a lot of arrays. Because of this, I created a helper class named “GridPosition”. This class makes sure that when calculating the gird position, there are only integers involved. This class also contains lots of operators for converting other data types and for calculations.

public class GridPosition
    {
        public int x;
        public int y;

        public GridPosition(int xPos, int yPos)
        {
            x = xPos;
            y = yPos;
        }

        public static GridPosition operator +(GridPosition a, GridPosition b)
        {
            return new GridPosition(a.x + b.x, a.y + b.y);
        }

        public static implicit operator Vector2(GridPosition a)
        {
            return new Vector2(a.x, a.y);
        }

Cell

Cells are the cubes that you see on the screen. These could be classes or monobehaviours, but we chose classes, because they don’t have useless boilerplate code, you don’t have to do world-to grid position conversions and the performance is way better, and the application is more scalable. Each cell has its own properties, position in a chunk, temperature properties, corrosion properties and behavior. The cells are divided in multiple groups.

A cell can be a stationary cell or a movable cell [2]. Stationary cells are always liquid and the only thing that can be changed are its temperature and corrosion properties. Movable cells can be solid, liquid or gas. All visible cells are inherited from 1 of the above.

Movable cells

These cells are all able to move. To do this, they check for open positions in the surrounding grid. Each state type has its own configuration of directions to check, but that’s not all. They also have to take velocity into account. Because of this, they have to look forward to their assigned directions.

To do this, they have a list of directions and loop through all these directions to get the best position to go to. These cells also need to be able to have velocity. The way I decided to fix this is to have a variable called “air time”. Every time the update-loop is called, and the cell moves the air time is incremented by 1. There also is a speed variable that represents the maximum speed. At last there is a “terminal velocity time” variable that represents the air time it would take to reach the maximum speed. The value of the velocity variable is used to see how many times you have to look forward in the assigned directions.

The “PixelCheck” method is used for special cases. Sand always sinks to the bottom of water, so the method is used to make sand see water as an empty space and swap those cells.

        private GridPosition MoveTo()
        {
            GridPosition option = default;

            int velocity = Mathf.RoundToInt(Mathf.Clamp(_airTime, 0, _terminalVelocity) / _terminalVelocity * _speed) +
                           1;

            if (_checkPositions == null || _checkPositions.Count == 0)
                return default;

            foreach (var checkPosition in _checkPositions)
            {
                option = ValidatePosition(checkPosition, velocity);
                if (option != default)
                    break;
            }

            return option;
        }
        
        private GridPosition ValidatePosition(GridPosition direction, int velocity)
        {
            GridPosition option = default;

            for (int i = 1; i < velocity + 1; i++)
            {
                GridPosition target = _position + direction * i;
                Cell cell = _chunk.GetPixelFromGrid(target);

                if (PixelCheck(cell, target, i))
                {
                    return default;
                }
                else if (cell == null)
                {
                    option = target;
                }
                else if (cell != null)
                {
                    break;
                }
            }

            return option;
        }

Solid movable cells

A way to create falling sand-like behavior is by doing a certain check when a sand cell is falling. This can be done pretty simply. When a cell falls it checks the position below it and if it’s not occupied, then that is its new position. If it is occupied it does the same with the position to the left under it and after that the one on the right under it [1, 2].

public class Sand : MovablePixel
    {
        protected override void Start()
        {
            base.Start();

            _checkPositions = new List<GridPosition>()
            {
                new GridPosition(0, -1),
                new GridPosition(-1, -1),
                new GridPosition(1, -1)
            };
        }

According to the creators of Noita this is almost 95% of their tech [1]. All moving cells do work this. They have a few directions where they should check and validate the position.

Liquid movable cells

This is basically the same as the solid movable simulation for the falling behavior, but you also check to the left and the right. If they are empty you move them in that direction. Because of the left and right check the water level will even out [1].

One thing that is different is that when a cell with a solid property, like a sand cell is above a liquid cell that they have to replace each other. This way the sand sinks to the bottom [2].

The same can be used for different types of liquids. If we add a variable named buoyancy, we can check which one is higher and make that one float to the top and the other liquid sink to the bottom.

So the sand cell sees the liquid cell below itself as empty and calls a separate method for swapping the cell position.

Gas movable cell

The simulation for gas is the inverse of the liquid simulation. Instead of it going down it goes up [1].

The difference between the other cells is that there is also a check to see if there is a falling solid cell (like sand) or a liquid cell (like water) above it. If so, then it swaps the 2. This way gas always bubbles out of a liquid and sand falls through it.

Cell Factory

To create all these cells, there is a cell factory that returns a newly created class of that type. You assign the cell type, position, and parent chunk.

CellFactory.CreateCell(CellType.Acid, cell.Position, chunk);

Fire Physics

As you can see the heat of the wood heats up the stone

To simulate fire, the cells all have a temperature. The cells check their neighbors every update loop for their temperature, add them together and get the average of it. This average temperature is the target temperature of this cell. If there is no neighbor it subtracts its temperature multiplied by the heat retention coefficient. This way the surrounding air will cool down a cell. If the cell is flammable and has already reached its combustion temperature, then it skips this and sets the target temperature as its current temperature plus its combustion temperature (this is the heat released when a flammable cell is burning).

public virtual void Heat()
    {
        if (IsCombusting())
        {
            ReceiveHeat(_temperature + _heatStatus.CombustionTemp);
        }
        else
        {
            float temp = 0;
            foreach (var position in GridPosition.Surrounding(Position))
            {
                Cell pixel = _chunk.GetPixelFromGrid(position);

                temp += pixel ? pixel.Temperature : -_temperature * _heatStatus.HeatRetension;
            }

            ReceiveHeat(temp / 4);
        }
    }
Burning of a flammable cel

When you have a non-flammable cell (like water) and a really hot cell in there, it doesn’t spread. It only radiates to its neighbors and when they sublimate to gas, they get pushed up and new water gets near the heat source and the circle starts again.

Heating/boiling of a non flammable cell (water)

This average of temperature is then passed through the receive heat method. Here the new temperature is decided by lerping between its current and target temperature with the heat retention coefficient as the step value. Here it also changes the color of a cell and checks if the cell is going to transition to gas.

public void ReceiveHeat(float amount)
    {
        _temperature = Mathf.Lerp(_temperature, amount, _heatStatus.HeatRetension);
        if (this)
            HeatChangeColor();
        if (_temperature >= _heatStatus.IgniteTemp)
        {
            Ignite();
        }
    }

This solution makes sure that even non-flammable cells receive, spread and disperse their heat. You could also let the cell radiate its heat to the neighboring cells, but I couldn’t get this to work. The heat only transfers to flammable cells and won’t cool down or just keep spreading. This method is easier to control and configure.

Corrosion Physics

To create this I gave each cell a material strength and health. The material strength determines if the acid is strong enough to start to dissolve the cell.

public virtual bool ReceiveCorrosion(float amount)
    {
        if (IsDissolvable(amount))
        {
            _materialHealth -= amount;

            CorrosionChangeColor();
            if (_materialHealth < 0)
            {
                Dissolve();
                return true;
            }
        }
        
        return false;
    }

The acid has an acid strength and dilution rate. The acid strength is how much it eats away the cell each frame. When an acid dissolves a cell it release gas and destroys itself.

public virtual bool IsDissolvable(float amount)
    {
        return amount >= _corrosionStatus.MaterialStrength;
    }
Acid dissolving sand into carbon gas

Acid also dissolves in water. So when it hits the water it influences the dilution and slowly changes it to water.

Cell Chunks

Cell chunks contain a 32×32 2D array of cells. This array is a representation of the chunk, where the first index represents the x and the second the y.

This array is used to keep track of where the cells are and also to update them. When the cell manager calls the update loop for this chunk it looks through all the cells of this chunk. [0, 0] is the left bottom and [31, 31] the top right. It then will check each cell from bottom left to top right.

A small problem that can arise here. If a cell moves up it can be called again and again. Because of this there has to be a check if a cell already has been altered in this frame. I called this checking if a cell is “locked”. This is because when you place a cell to the position above it, it will then place it above it again, then it will call it multiple times till it’s out of the chunk.  

To make sure all cell can move correctly and also between chunks this class contains multiple methods for cell behavior like moving, swapping, replacing with new and removing. All these methods make sure that the cell is not only moved, but also assigned to the correct chunk.

        public void MovePixel(Cell cell, GridPosition destination)
        {
            if (cell == null)
                return;

            //Remove cell from current position to be replaced
            cell.Chunk.RemovePixel(cell);

            //check if out of bounds destination
            Vector2 outOfBoundsDirection = GetNeighborPosition(destination);
            if (outOfBoundsDirection != Vector2.zero)
            {
                //Searches for neighboring chunks
                if (_neighbors.ContainsKey(outOfBoundsDirection))
                {
                    GridPosition dest = new GridPosition(destination.x - (int)outOfBoundsDirection.x * (int)PixelManager.Instance.ChunkSize.x,
                        destination.y - (int)outOfBoundsDirection.y * (int)PixelManager.Instance.ChunkSize.y);

                    _neighbors[outOfBoundsDirection].SetPixelPosition(cell, dest);
                    return;
                }
                else
                {
                    cell = null;
                    return;
                }
            }

            SetPixelPosition(cell, destination);
        }

        public void SwapPixels(Cell a, Cell b)
        {
            var posA = a.Position;
            var chunkA = a.Chunk;
            
            var posB = b.Position;
            var chunkB = b.Chunk;
            
            chunkB.SetPixelPosition(a, posB);
            chunkA.SetPixelPosition(b, posA);
        }
    
        public void RemovePixel(Cell cell)
        {
            _cells[cell.Position.x, cell.Position.y] = null;
        }

        public Cell ReplacePixel(Cell cell, CellType replacement)
        {
            Cell replacementCell = CellFactory.CreateCell(replacement, cell.Position, this);

            GridPosition position = new GridPosition(cell.Position.x, cell.Position.y);
            RemovePixel(cell);
            SetPixelPosition(replacementCell, position);

            return replacementCell;
        }

Besides this, it also contains multiple helper methods for retrieving cells from the grid.

        private Cell GetPixelFromGrid(int x, int y)
        {
            return _cells[x, y];
        }

        public Cell GetPixelFromGrid(GridPosition position)
        {
            Vector2 outOfBoundsDirection = GetNeighborPosition(position);
            if (outOfBoundsDirection != Vector2.zero)
            {
                if (_neighbors.ContainsKey(outOfBoundsDirection))
                {
                    return _neighbors[outOfBoundsDirection].GetPixelFromGrid(
                        new GridPosition(position.x - (int)outOfBoundsDirection.x * (int)PixelManager.Instance.ChunkSize.x,
                            position.y - (int)outOfBoundsDirection.y * (int)PixelManager.Instance.ChunkSize.y));
                }

                return null;
            }

            return GetPixelFromGrid(position.x, position.y);
        }

I created these chunks for scalability in the future. If everything is in 1 big chunk the performance could get really bad. Now we could disable chunks that are out of frame, render them async or let them be dormant till a new cell enters it.

Cell Manager

The cell manager keeps track of all the chunks. Every time the update loop is called, it first loops through all the chunks and calls their update loop and after that, it will run the visualization of all the chunks. The visualization is explained in the next chapter.

The cell manager holds a list with all the chunks and handles the translation of the world to grid position for “painting” the cells.

        public GridPosition ConvertWorldToGridPosition(Vector2 worldPosition)
        {
            worldPosition -= (Vector2)transform.position;
            var multiple = 1 / PixelManager.Instance.PixelSize;
            return new GridPosition(
                Mathf.RoundToInt(PixelManager.Instance.ChunkSize.x / 2 + worldPosition.x * multiple),
                Mathf.RoundToInt(PixelManager.Instance.ChunkSize.y / 2 + worldPosition.y * multiple));
        }

Visualization

At the moment we only have different cells stored in arrays as chunks being controlled by a manager. We only have data. So to visualize this we need to create a sprite. After the update loop of all the chunks has been run, the cell manager loops through all the chunks and calls the bake method.

        public void Bake()
        {
            if (!_display)
                return;

            int x = (int)PixelManager.Instance.ChunkSize.x;
            int y = (int)PixelManager.Instance.ChunkSize.y;

            for (int i = 0; i < x; i++)
            {
                for (int ii = 0; ii < y; ii++)
                {
                    _cellColors[i * x + ii] = _cells[i, ii] != null ? _cells[i, ii].RenderColor : new Color(1, 0, 0, 0);
                }
            }

            _texture.SetPixels32(_cellColors);
            _texture.Apply();

            Sprite sp = Sprite.Create(_texture, new Rect(0, 0, x, y), new Vector2(.5f, .5f), 4);
            _display.sprite = sp;
        }

The bake method creates a 32×32 sprite and converts the cell array into a color array. This way, a whole chunk is rendered by 1 sprite [4].

You could also create a custom shader for even better performance, or use gameobjects for each cell for easier debugging. I chose this method because it was an easy but effective solution. This is a lot more performant than using gameobjects.

Result

We now have a good foundation for an interactive world. We have a few materials that can interact with each other and a good performing demo. This could be even further optimized if we would like to scale this project to a large world and with a simple world-generation algorithm and a player script you already have a game.

Further Optimization

This project already has some optimization features like the sprite baking and chunks, but you could even go further.

I wanted to explore ways you could create the building blocks for world generation. I chose for cellular automata, because it is pretty lightweight, you can do a lot of optimization, it is easily scalable, and you can create some cool effects.

Cells

You could gain some performance by using object pooling. There are a lot of different type of cells, so you could even create different amounts for different cells. This could easily be added to the cell factory.

Chunks

The chunks could be run async, but you have to make sure that the chunks aren’t checked twice. So there has to be a check to see if it was already updated, or you make a rule that neighboring chunks can’t be checked by the same async [2].

If a chunk doesn’t have any cells that interact with anything, then it would be a waste to keep looping through it. A way you can optimize this is by making the cell dormant if there are no cells in the chunks that need to be updated. When a new cell enters the chunk, then the chunk becomes awake again [2].

If you have a map not all the chunks need to be updated. There could be some culling done so that some far-away chunks get disabled until you get close to them [1, 2].

Engine

I created this project in Unity for ease of use, and I wanted a web build, but this gives a lot of boilerplate that isn’t really needed. I think that if you wrote this with a simple graphics library, it would run even better.

Graphics

At the moment I found that the sprite baking method was easy to use, but I think it would work even better if you could just pass the position and the colors directly to the GPU. This way you would skip the whole baking step.

Sources

  1. “How to Code a Falling Sand Simulation (like Noita) with Cellular Automata.” Www.youtube.com, MARF, 23 May 2021, www.youtube.com/watch?v=5Ka3tbbT-9E&ab_channel=MARF. Accessed 18 Mar. 2024.
  2. Purho, Petri , and Nolla Games. “Exploring the Tech and Design of Noita.” Www.youtube.com, GDC, 2 Jan. 2020, www.youtube.com/watch?v=prXuyMCgbTc&pp=ygUJZ2RjIG5vaXRh. Accessed 18 Mar. 2024.
  3. “How to handle a MASSIVE amount of Game Objects?,” Unity Forum. https://forum.unity.com/threads/how-to-handle-a-massive-amount-of-game-objects.232874/#:~:text=unity%20has%20a%20hard%20cap%20of%2065%20k%20gameobjects. (accessed Apr. 02, 2024).
  4. “Generating Texture2D with custom color array at runtime seems to be not working correctly,” Unity Forum. https://forum.unity.com/threads/generating-texture2d-with-custom-color-array-at-runtime-seems-to-be-not-working-correctly.751724/ (accessed Apr. 02, 2024).

One thought on “Fluid Simulation using Cellular Automata

  1. Vervolgstappen voor 20 maart ’24:

    1. Wat is het eindproduct wat je gaat maken? Denk daar alvast even goed over na.
    2. Probeer niet alleen maar nieuwe elementen toe te voegen, maar maak er uiteindelijk een geheel van 🙂
    3. Shader gebruiken om de postie en state van elementen in je wereld te bepalen en te updaten.

Geef een reactie

Je e-mailadres wordt niet gepubliceerd. Vereiste velden zijn gemarkeerd met *