Cellular automata is an n-dimensional set of cells that interact together with a given set of rules. Over time, these interactions have given way to patterns, and modifying the rules will modify the pattern. The most famous example is probably Conway's Game of Life where cells based on an extremely simple rule set create the most amazing, evolving patterns. In games, cellular automata is usually found simulating liquids in a tile– or block–based game worlds.
In this recipe, we'll explore such a liquid system based on a 2D grid. Since it's 2D, there can be no true waterfalls, but it can still be applied to a heightmap (which we'll show) to create natural-looking rivers.
Performance becomes an issue with large cellular automata, which will become evident as they're scaled up. To counter this, we'll also look at a couple of different techniques to keep the resource consumption down. The following image shows water running down the slope of a mountain:
This recipe requires height differences to make it interesting. A heightmap will work very well.
The model we'll develop will evolve around cells that are defined by two parameters: the height of the ground it resides on and the amount of water in it. If the height and amount of water combined are higher than a neighboring cell, water will pour out of it and into its neighbor. To make sure the cells are updated simultaneously, all of the water pouring into a cell will be stored in a separate field and applied at the end of the update cycle. This ensures that water can only move one tile through the field in one update. Otherwise, the same unit of water might travel across the whole grid in one update as we loop through the tiles.
The example mentions a CellUtil
class. The code for this can be found in the The CellUtil class section in Appendix, Information Fragments.
The following steps will produce flowing water:
WaterCell
. It needs a float field called amount
, another float field called terrainHeight
, and one integer field for the current direction of the flow. It should also store any incoming water in a float field called incomingAmount
.amount
, add a method called adjustAmount
that takes a float variable called delta
as the input. The delta
variable should be added to amount
.compareCells
that will move the water between cells. It takes another cell (where the water is coming from) as the input.float difference = (otherCell.getTerrainHeight() + otherCell.getAmount()) - (terrainHeight + amount);
amountToChange = difference * 0.5f; amountToChange = Math.min(amountToChange, otherCell.getAmount());
incomingAmount
field (we don't update the amount for this until everything has been calculated).otherCell.adjustAmount(-amountToChange);
WaterFieldControl
that extends AbstractControl
.WaterCell
called waterField
. To display it, we'll add a Node
class called water
and a Material
class called material
.setSpatial
method should be overridden and the spatial
variable passed has to be an instance of Node
. Look for a terrain among its children; once found, populate waterField
with WaterCells
, applying the height of the terrain for each tile as follows:for(int x = 0; x < width; x++){ for(int y = 0; y < height; y++){ WaterCell cell = new WaterCell();cell.setTerrainHeight(((Terrain)s).getHeight(new Vector2f(x, y))); waterField[x][y] = cell; } }
updateCells
. For this example, define a source of water that will never run out right from the beginning by setting the amount of water in one of the middle tiles as 1.waterField
array in a nested for
loop.WaterCell cell = waterField[x][y]; float cellAmount = cell.getAmount(); if(cellAmount > 0){ int direction = cell.getDirection(); for(int i = 0; i < 8; i++){ int[] dir = CellUtil.getDirection((direction + i) % 8);
compareCells
to try to dump water in it. If this try is successful, set the direction of the neighborCell
object to the tested direction to represent the flow of water, as follows:WaterCell neighborCell = waterField[x+dx][y+dy]; if(cell.getAmount() > 0.01){ floatadjustAmount = neighborCell.compareCells(cell); if(adjustAmount > 0){neighborCell.setDirection(CellUtil.getDirection(dx, dy)); } }
waterField
array once again. This time add incomingWater
to the current amount of the cell and then set incomingWater
to 0
.createGeometry
.Spatial
of the control has a child called Water. If it does, detach it.Node
class called water
. Its name should be Water
as this is an identifier in this example:water = new Node("Water");
waterField
array. If any cell's amount is more than 0, you should add a Geometry
object that represents it. getGeometry
method to avoid recreating the Geometry
field unnecessarily. First of all, set geometry
to null
if the amount
value is 0.geometry
is null, create a new geometry
instance with a box-like shape as follows:geometry = new Geometry("WaterCell", new Box(1f, 1f, 1f));
geometry.setLocalScale(1, 1f + amount, 1);
geometry
field, which might be null.WaterFieldControl
class, if the returned geometry
variable is not null, set its location and attach it to the water
node as follows:g.setLocalTranslation(x, -1f + cell.getTerrainHeight() + cell.getAmount() * 0.5f, y); water.attachChild(g);
water
node and then batch it to increase the performance before attaching it to the control's spatial
, as follows:water = GeometryBatchFactory.optimize(water, false); water.setMaterial(material); ((Node)spatial).attachChild(water);
controlUpdate
method to call updateCells
and createGeometry
.WaterFieldControl
class that we'll add to a Node
class that contains a Terrain
instance.Material
instance with Unshaded
MaterialDefinition
and applying a blueish color to it or an advanced custom shader. It is then applied to the WaterFieldControl
class via the setMaterial
method.The beauty of cellular automata is the simplicity with which they work. Each cell has a very basic set of rules. In this example, each cell wants to even out the water level with a neighboring cell. As we go through iteration, the water moves downhill.
It's usually fairly easy to get the automation up and running, but it can take a while to get everything right. For example, even if each cell's amount is updated correctly, we will get weird oscillating water effects if the flow's direction doesn't work correctly. The reason is that there would be a preferred direction the water will take in a new cell. This direction might be the opposite of where it came from, making it want to move back to the cell it came from. Picking a random direction might work in that case, but it makes it more difficult to predict the behavior. This is why we use the direction of the water in the cell it came from. Naturally, the water will have some momentum and will continue to flow until it is stopped.
One thing that can be tricky to grasp at first is the reason why we don't update the water amount directly. The reason is that if water moves from cell x to cell x+1, that water would instantly become available for x+1 once the update
method reaches there; also, it could be moved to x+2 and so on. We can't think of the water as real time, and that's why we first perform an outgoing operation on all the cells before we apply the incoming water. We also don't change the amount in the cell we're currently checking for the same reason. Instead, we move any water left in a cell to the incomingWater
field.
The main challenge with the method is usually related to performance. Calculating can be expensive and rendering even more so. With a system like this, it's ever-changing and we might be forced to recreate the mesh in every frame. Rendering each cell on its own quickly becomes impossible, and we must use batching to create a single mesh. Even this is not enough, and in this example, we store the cell's geometry
field so we don't have to recreate it unless the water level is 0 in a cell. We also scale the cell's geometry
field if the water level changes as this is much quicker than creating a new Mesh
class for it. The drawback is the additional memory that is used by storing it.
We also made it optional to update the water in every frame. By lowering it to a set amount of updates every second (in practice, its own frame rate), we could severely lessen the impact of the performance. This could also be taken further by only updating parts of the water field with every update, but efforts must be taken to conserve the amount of the water. We could also separate the field into smaller batches and check whether any of these need to be reconstructed.
There are ways to take this example further for those who wish. One could play around with the amount of water that each cell shares. This will make it more expensive to calculate but might give a smoother result. It's also possible to add pressure as a parameter, making it possible for water to move up the slopes. Evaporation might be a way to remove water from the system and clean up any puddles left by the main flow.
18.118.226.66