A falling sand game as a cellular automaton, in WebGL.
Visit ericleong.github.io/sand.js for a demo.
Open index.html
in a browser of your choice after downloading this repository.
New cells and rules can be defined in a space-delimited text file. The format is based off of mods for wxSand.
A cell is a single element in the game. Every pixel in the sandbox should correspond to a cell, unexpected behavior may occur otherwise. A cell is defined by
cell <name> <red> <green> <blue> <density>
Names cannot have spaces. Valid color values are integers from 0
to 255
, and valid density values range from 0.0
to 0.99
(it is still mapped to an 8-bit integer, so precision is not infinite).
Here is an example:
cell empty 0 0 0 0.5
empty is the default background, and is black with a density of 0.5
. Cells with higher densities will fall, and cells with lower densities will float. Here is one possible way to write sand:
cell sand 255 255 255 0.95
- There is a limit of 256 cell types, and all names must be unique.
- Densities of zero may behave unexpectedly!
- Densities of
1.0
serve as immobile "walls". It is still important to define their rules correctly though.
Rules govern the interactions between cells. They are defined like this:
rule <current cell> <neighboring cell> <new current cell> <new neighboring cell>
Obviously, each cell has many neighbors, only the one with the highest priority is chosen. This decision is based on density: if a cell with a density of 1.0
is surrounded by other cells with a density of 0.5
, the cell immediately below it is chosen and the game looks up the rule for each of them. Cells are only compared with cells that are directly adjacent, including diagonally. For example, for falling sand
(using the definitions above):
rule empty sand sand empty
When an empty
cell is below a sand
cell, it becomes a sand
cell, and the sand
cell becomes an empty
cell. This simulates falling at the cell level.
A cell that does not interact with empty
(and therefore does not move) can be defined as:
rule wall empty wall empty
More complex interactions can be simulated by creating new cells, rather than simply swapping them. For example:
rule fire sand fire fire
This causes fire
to spread in the presence of sand. Note that it will only spread in the direction governed by its density.
You can also create entirely different cells, like this:
rule fire sand smoke fire
Just make sure to put out the fire!
Note that if there is more than one rule for a pair of cells, the only the last one is considered.
Falling sand games tend to work by operating on a single pixel and swapping it with neighboring cells. First, the cell below is checked:
● | ||
↓ |
and then the cells on the left and right:
● | ||
↙ | ↘ |
Most falling sand games are written for the CPU and modify the sandbox (the rectangular region with all the cells) in place. One method involves iterating through the entire world and moving each cell, marking moved cells so that they are not moved more than once a frame. The most common optimization is to keep a list of "active" (not empty) cells and only move those. This unfortunately leads to a simulation that is slower when there are more cells, which is when it is the most interesting!
Neither method is easily parallelizable, which is necessary for it to run on the GPU.
Falling sand games can be modeled as cellular automata, with two states, occupied
and unoccupied
, but the usual approaches (like the above) are asynchronous, since they modify the global state one cell at a time. The difficulty of a synchronous approach is the conservation of cells:
↘ | ↙ | |
---|---|---|
● |
Two cells may contend for the same cell at the same time, and this may result in one cell disappearing. This can be mitigated by having cells always choose to fall left (if they can't fall downward). So the rules could be
occupied
cell- if below is unoccupied, become unoccupied
- if below left is unoccupied, become unoccupied
unoccupied
cell- if above is occupied, become occupied
- if above right is occupied, become occupied
This is the cellular automata version of "swapping" cells. Yet this is not enough. Consider this case:
○ | ● |
---|---|
↙ | ○ |
The cell with the black circle shoud not fall to the lower left because it is blocked by the cell to the left, and the empty cell will become occupied because of the cell above it.
sand.js checks this and many other cases to conserve cells. This is why non-interacting cells ("walls") are a special density - cells that don't move violate the assumptions above.
In the above case, different things happen if a cell is occupied
or unoccupied
. For a cellular automaton with two states, this is fine, but if we want to add more states, we need a more general method for deciding if two cells should swap.
One solution is to attach an additional parameter to each pixel: density. First compares the current cell's density to the cell below it. If it is higher, then a swap occurs, if not, it checks the cell above. This is useful becauase it conserves cells - the cell below will notice that it should swap with the cell above, and cells are conserved.
Occasionally, cells may stack so that a cell needs to swap with both the one above it and the one below it:
2 |
---|
1 |
0 |
This situation is impossible to resolve by only looking at the local neighborhood using a synchronous approach, if we swap 0
and 1
at the same time as 1
and 2
, we could end up in one of two situations:
a | b |
---|---|
1 | 1 |
2 | 0 |
1 | 1 |
Obviously, cells are not conserved in either case. It may seem like a good idea to swap 0
and 2
, but this simply hides the problem by expanding the neighborhood, and the obvious next case is layering 4 cells deep. Another solution is to simply not do one of the swaps, thus requiring three iterations to solve the situation:
#0 | #1 | #2 | #3 |
---|---|---|---|
2 | 1 | 1 | 0 |
1 | 2 | 0 | 1 |
0 | 0 | 2 | 2 |
Unfortunately this requires the bottom cell to know the density of the top cell, but it is a worthwhile tradeoff to conserve cells. Also, if the center cell and the top cell don't actually swap (possibly specified in the rules), this solution also fails.
Cells are implemented as pixels on a webgl texture. The data is laid out like this:
red | green | blue | alpha |
---|---|---|---|
cell index | unused | unused | density |
The cell index
is dynamically generated when loading the config - it generally follows the order in the config, unless there are duplicates. When drawing the sand to the screen a 256x1
lookup table is used to determine the color. For example, the basic empty
and sand
case has a index lookup table like:
index | color |
---|---|
0 | black |
1 | amber |
Rules are implemented using another texture table. Once it is determined that two cells are interacting, the rules are queried to determine the result. The indices for the two interacting cells are used as coordinates in the rules table, a 256x256
texture. The table tells the current cell what cell to become.
For example, if 0
(empty) is the current cell and it is interacting with a 1
(sand) cell, then we go to (0, 1)
in the rules table:
index | 0 | 1 |
---|---|---|
0 | 0 |
1 |
1 | 0 |
1 |
which states that the current cell must now be 1
, or sand
. Each rule in the config file corresponds to two entries in the rules table. There is a limit of 256 cell types, which would require 32768 rules.