A falling sand simulation models individual grains of sand that move downward due to gravity, bump into and interact with other sand particles, and can slide or tumble sideways when stacked unstably.
Basic Rules
- Rule 1: If the cell below is empty, swap the cell with the cell below
- Rule 2: If the cell left is empty, swap the cell with the cell left
- Rule 3: If the cell right is empty, swap the cell with the cell right
In Code:
function basicRules(idx, belowIdx, leftIdx, rightIdx, grid) {
// Rule 1: If the cell below is empty, swap the cell with the cell below
if (grid.isEmpty(belowIdx)) {
grid.swap(idx, belowIdx)
} else {
// randomize left and right falling direction
if (Math.random() < 0.5) {
let swap = leftIdx
leftIdx = rightIdx
rightIdx = swap
}
if (leftIdx > -1 && grid.isEmpty(leftIdx)) {
// Rule 2: If the cell left is empty, swap the cell with the cell left
grid.swap(idx, leftIdx)
} else if (rightIdx > -1 && grid.isEmpty(rightIdx)) {
// Rule 3: If the cell right is empty, swap the cell with the cell right
grid.swap(idx, rightIdx)
}
}
}
With these simple rules, sand pouring forms neat triangular piles.
click to restart
Cellular Automata
We can also think of this as a sort of CA. Encoding the 3 cells below as a 3 bit number, we can enumerate all 7 possible states:
- [0,0,0] => 0 => [0,X,0]
- [0,0,1] => 1 => [0,X,1]
- [0,1,0] => 2 => [X,1,X] (special case)
- [0,1,1] => 3 => [X,1,1]
- [1,0,0] => 4 => [1,X,0]
- [1,0,1] => 5 => [1,X,1]
- [1,1,0] => 6 => [1,1,X]
- [1,1,1] => 7 => [1,1,1]
Which simplifies to 4 rules:
- 0, 1, 4, 5 -> swap with center cell
- 3 -> swap with left cell
- 6 -> swap with right cell
- 2 -> apply special case
- 7 -> no action (full)
special case: for the case where both left and right are available, swap with the left cell with 50% probability.
In code: I'm not sure this is more clear, but it's an interesting exercise.
function caRules(idx, belowIdx, leftIdx, rightIdx, grid) {
// Encode the 3 cells below as a 3-bit number: left (4), below (2), right (1)
const encoded =
(grid.isEmpty(leftIdx) ? 0 : 4) + //
(grid.isEmpty(belowIdx) ? 0 : 2) + //
(grid.isEmpty(rightIdx) ? 0 : 1)
if ([0, 1, 4, 5].includes(encoded)) {
grid.swap(idx, belowIdx)
} else if (encoded === 3) {
grid.swap(idx, leftIdx)
} else if (encoded === 6) {
grid.swap(idx, rightIdx)
} else if (encoded === 2) {
// Randomly choose between left and right if both are available
const target = Math.random() < 0.5 ? leftIdx : rightIdx
grid.swap(idx, target)
} // 7 is a no-op (full)
}
Same as before, but with the CA rules.
click to restart
Varying Placement & Colors
Let's see if we can make this more interesting by varying the placement and colors of the sand. Two "cursors" move independently and drop sand with different colors.
click to restart
Adding Weight
For the case where a particle collides with another particle below it, we can add another dimension to the rules. If the particle below has less "weight" assigned to it, we can attempt to push it asside (left or right).
- Give sand particles a random weight on generation.
- When it collides with a particle of less weight, it will attempt to push that particle asside if there is open space.
- Shade the heavier particles to be darker.
In code:
function attemptDisplace(idx, leftIdx, belowIdx, rightIdx, encoded, grid) {
// make sure its actually heavier
if (grid.getWeight(belowIdx) > grid.getWeight(idx)) {
return false
}
if (encoded === 3) {
// we can push the particle to the left open space
grid.swap(belowIdx, leftIdx)
grid.swap(idx, belowIdx)
return true
} else if (encoded === 6) {
// we can push the particle to the right open space
grid.swap(belowIdx, rightIdx)
grid.swap(idx, belowIdx)
return true
} else if (encoded === 2) {
// both left and right are open, so we get to choose
const target = Math.random() < 0.5 ? leftIdx : rightIdx
grid.swap(belowIdx, target)
grid.swap(idx, belowIdx)
return true
}
return false
}
function weightedRules(idx, belowIdx, leftIdx, rightIdx, grid) {
// Encode the 3 cells below as a 3-bit number: left (4), below (2), right (1)
const encoded =
(grid.isEmpty(leftIdx) ? 0 : 4) + //
(grid.isEmpty(belowIdx) ? 0 : 2) + //
(grid.isEmpty(rightIdx) ? 0 : 1)
if ([0, 1, 4, 5].includes(encoded)) {
grid.swap(idx, belowIdx)
} else if (encoded === 3) {
if (!attemptDisplace(idx, leftIdx, belowIdx, rightIdx, encoded, grid)) grid.swap(idx, leftIdx)
} else if (encoded === 6) {
if (!attemptDisplace(idx, leftIdx, belowIdx, rightIdx, encoded, grid)) grid.swap(idx, rightIdx)
} else if (encoded === 2) {
if (!attemptDisplace(idx, leftIdx, belowIdx, rightIdx, encoded, grid)) {
// Randomly choose between left and right if both are available
const target = Math.random() < 0.5 ? leftIdx : rightIdx
grid.swap(idx, target)
}
} // 7 is a no-op (full)
}
Weighted Sand:
click to restart
Resources
- Isoplanet - Joshua Bagley
- Isoplanet - Artblocks
- Making a Falling Sand Simulator
- Coding Train
- This is Sand
- Sandspiel
Further Research
Types of rules to explore
- Rock/Wood
- Fire
- Erosion (global)
- Water
- Plants / Grass
- Sky (global)
- Birds
Other Ideas
- Textures to init sand state?
- Add Sky and background
- Simple erosion simulation