Procedural hydrology: dynamic simulation of rivers and lakes

Note: the full source code of the project is posted on Github [ here ]. The repository also contains detailed information on how to read and use the code.

After implementing the particle-based hydraulic erosion simulation, I decided that it would be possible to expand this concept to simulate other aspects of surface hydrology.

I researched the existing methods of procedural generation of rivers and lakes, but the results found did not suit me.

The main objective of many methods is to create river systems (very beautiful) using various algorithms (sometimes based on a previously created elevation map or inverse problem), but they lack a strong realistic relationship between the topography and hydrology.

In addition, processing of the water model on the relief as a whole is considered on some resources and a simulation of highly complex fluids is used.

In this article, I will demonstrate my attempt to overcome these problems with a technique that extends the ability to simulate particle-based hydraulic erosion. I will also explain how, in general, I solve the problem of “water on the relief”.

In my method, I strive both for simplicity and for realism at the cost of a slight increase in the complexity of the underlying erosion system. I recommend reading my previous article about this system [ here , translation on Habré], because the new model is based on it.


This system is capable of quickly generating a very realistic looking terrain with hydrology. This video was rendered in real time. The system is capable of generating an infinite number of such landscapes.

Explanation: I am not a geologist, so I created the system based on my knowledge.

Hydrology concept


I want to create a generative system that can simulate many geographical phenomena, including:

  • River and stream migration
  • Natural waterfalls
  • Canyon Formation
  • Swelling of the soil and floodplain

Consequently, the systems of hydrology and topography must be dynamic and closely related. The particle-based hydraulic erosion system already has the basic aspects necessary for this:

  • The relief affects the movement of water
  • Erosion and sedimentation affect the terrain

In fact, this system simulates the erosion caused by rain, but is not able to convey many other influences:

  • In a moving stream, water behaves differently.
  • In a standing pool, the water behaves differently

Note: I will often mention streams and pools . The assumption is made in the model that these are two-dimensional large-scale phenomena. They greatly reduce the complexity of the model.

Most of the above geographical phenomena can be conveyed using the model of flows and basins. Ideally, they should borrow and enhance the realism of a particle-based system.

Simplified hydrological model


Storing information about flows and pools in one or more data structures (graphs, objects, etc.) is too complex and limiting our capabilities.

Therefore, our hydrological model consists of two maps: flow maps and basin maps .

Note: do not forget that they are modeled as 2D systems.

Stream and Pool Maps


The flow map describes the flowing water on the surface (streams and rivers). It stores the time-averaged particle positions on the map. Old information is slowly being deleted.

The basin map describes the still water on the surface (puddles, ponds, lakes, oceans). It stores the depth of water in the corresponding position of the map.

Note: stream and pool maps are arrays of the same size as the height map.


Relief with hydrological impact. The layer of water in the render is taken from hydrological maps.


Combined hydrological maps. Light blue is a stream map; dark blue is a pool map.

These maps are generated and connected by particles moving water along the terrain. Adding these hydrological maps also gives us time-independent information that allows for the interaction of particles with their simulation separately. Particles can interact through the use of these cards to obtain parameters that affect their movement.

Note: the stream map is displayed after passing through the ease-in-out function and is rendered on the terrain based on this. To obtain finer / sharper flows (or to ignore lower values ​​on wide flat areas), this display can be modified or set threshold values ​​for it.

Water as a particle


Water is presented in the form of a discrete mass (“particles”) having a volume and moving along the surface of the relief. It has several parameters that affect its motion (friction, evaporation rate, deposition rate, etc.).

This is the basic data structure used to simulate hydrology. Particles are not stored , but are used only for interaction between maps of heights, flows and pools.

Note: the concept of a particle is explained in more detail in a previous post [ translation on Habré] (and an infinite number of other resources).

Hydrological cycle and map interaction


Maps interact with each other through a hydrological cycle. The hydrological cycle consists of the following steps:

  • Creating a particle on the terrain
  • (.. ).
  • , .
  • , .
  • , ( ) .
  • .

Throughout the system, there are only two algorithms: Descend (descent) and Flood (flooding) . Descending particles change the map of flows, and flooding particles change the map of basins. These algorithms are described in detail below.


One-dimensional diagram of the hydrological model. Particles are created on the terrain and cyclically processed by two algorithms: Descend and Flood. In the process, the maps of the basins and flows change, in turn affecting the movement of particles.

Implementation


Below I will explain the full implementation of the system used to generate the results, and present code examples.

Note: I will only show relevant pieces of code. More information can be found in the repository on Github. All relevant parts of the code are in the “water.h” file.

Particle class


The structure of the Drop particle is identical to the structure of the previous system. Descend and flood are now members of the structure because they act on only one particle at a time.

struct Drop{
  
  //... constructors

  int index;                         //Flat Array Index
  glm::vec2 pos;                     //2D Position
  glm::vec2 speed = glm::vec2(0.0);
  double volume = 1.0;
  double sediment = 0.0;

  //... parameters
  const double volumeFactor = 100.0; //"Water Deposition Rate"

  //Hydrological Cycle Functions
  void descend(double* h, double* stream, double* pool, bool* track, glm::ivec2 dim, double scale);
  void flood(double* h, double* pool, glm::ivec2 dim);
};

An additional parameter is the volume factor, which determines how flooding transfers volume to the water level.

Descent algorithm


The descent algorithm is almost the same as the simple particle erosion algorithm. He receives additional input “track” - an array in which he writes all the positions visited by him. An array is needed to build stream maps in the future.

void Drop::descend(double* h, double* stream, double* pool, bool* track, glm::ivec2 dim, double scale){

  glm::ivec2 ipos; 

  while(volume > minVol){

    ipos = pos; //Initial Position
    int ind = ipos.x*dim.y+ipos.y; //Flat Array Index

    //Register Position
    track[ind] = true;

    //...
  }
};

The set of parameters is modified by flow and pool maps:

//...
  //Effective Parameter Set
  double effD = depositionRate;
  double effF = friction*(1.0-0.5*stream[ind]);
  double effR = evapRate*(1.0-0.2*stream[ind]);
//...

Note: I found out that such a parameter change works well.

In the previous system, particles could get out of this cycle and be destroyed only with complete evaporation or going beyond the boundaries of the relief. Now two additional exit conditions have been added here:

//... nind is the next position after moving the particle
  
  //Out-Of-Bounds
  if(!glm::all(glm::greaterThanEqual(pos, glm::vec2(0))) ||
     !glm::all(glm::lessThan((glm::ivec2)pos, dim))){
       volume = 0.0;
       break;
  }

  //Slow-Down
  if(stream[nind] > 0.5 && length(acc) < 0.01)
    break;

  //Enter Pool
  if(pool[nind] > 0.0)
    break;

//...

If the particle does not have sufficient acceleration and is surrounded by other particles, or directly enters the pool, then it prematurely completes the descent with all its remaining volume and proceeds to the flooding algorithm.

Note: the overflow condition also resets the volume so that the particles do not go over to the flooding algorithm.

Flooding algorithm


A particle with the remaining volume can flood from its current position. This happens if it stops descending (there is no acceleration) or enters an existing pool.

The flooding algorithm transfers the volume of the particle to the increased water level, changing the map of the basin. The technique is to incrementally increase the water level by a fraction of the particle’s volume using the “test plane”. As the water level rises, the particle volume decreases.


Flooding algorithm animation. The test plane and water level increase gradually, reducing the particle volume. If a leak is found, then the remaining volume moves to the leak point to perform the descent.

At each step, we perform a flood-fill from the particle’s position (that is, recursively check the positions of neighbors), adding all positions located above the original plane (current water level) and below the test plane to the “flood set”. This is the area of ​​relief that is part of the pool.

During the fill, we check for leaks. These are points from the set of flooding that are below the test plane AND of the original plane. If we find several leak points, we select the lowest one.

void Drop::flood(double* height, double* pool, glm::ivec2 dim){

  index = (int)pos.x*dim.y + (int)pos.y;
  double plane = height[index] + pool[index];  //Testing Plane
  double initialplane = plane;                 //Water Level

  //Flood Set
  std::vector<int> set;
  int fail = 10; //Just in case...

  //Iterate while particle still has volume
  while(volume > minVol && fail){

    set.clear();
    bool tried[dim.x*dim.y] = {false};

    //Lowest Drain
    int drain;
    bool drainfound = false;

    //Recursive Flood-Fill Function
    std::function<void(int)> fill = [&](int i){

      //Out of Bounds
      if(i/dim.y >= dim.x || i/dim.y < 0) return;
      if(i%dim.y >= dim.y || i%dim.y < 0) return;

      //Position has been tried
      if(tried[i]) return;
      tried[i] = true;

      //Wall / Boundary of the Pool
      if(plane < height[i] + pool[i]) return;

      //Drainage Point
      if(initialplane > height[i] + pool[i]){

        //No Drain yet
        if(!drainfound)
          drain = i;

        //Lower Drain
        else if( pool[drain] + height[drain] < pool[i] + height[i] )
          drain = i;

        drainfound = true;
        return; //No need to flood from here
      }

      //Part of the Pool
      set.push_back(i);
      fill(i+dim.y);    //Fill Neighbors
      fill(i-dim.y);
      fill(i+1);
      fill(i-1);
      fill(i+dim.y+1);  //Diagonals (Improves Drainage)
      fill(i-dim.y-1);
      fill(i+dim.y-1);
      fill(i-dim.y+1);
    };

    //Perform Flood
    fill(index);

    //...

Note: for simplicity, an eight -way fill algorithm is used here . In the future, it will be possible to implement it more efficiently.

After identifying the many flooding and leak points, we change the water level and the pool map.

If a leak point is found, we move the particle (and its “overflow” volume) to the leak point so that it can begin to descend again. Then the water level goes down to the height of the leak point.

    //...

    //Drainage Point
    if(drainfound){

      //Set the Particle Position
      pos = glm::vec2(drain/dim.y, drain%dim.y);

      //Set the New Waterlevel (Slowly)
      double drainage = 0.001;
      plane = (1.0-drainage)*initialplane + drainage*(height[drain] + pool[drain]);

      //Compute the New Height
      for(auto& s: set) //Iterate over Set
        pool[s] = (plane > height[s])?(plane-height[s]):0.0;

      //Remove some sediment
      sediment *= 0.1;
      break;
    }

    //...

Note: when water level decreases due to leakage, I found out that this works best with a low leakage rate. In addition, the elimination of part of the sedimentary rocks helps the implementation.

Due to this, new particles entering the filled pool instantly move their volume to the leak point, because adding volume to the pool displaces the same volume from it.

If the leak point is not found, then we calculate the volume under the test plane and compare it with the volume of the particle. If it is less, then we remove the volume from the particle and adjust the water level. Then the test plane rises. If it is larger, then the test plane is lowered. The process is repeated until the particle runs out of space or a leak point is found.

    //...

    //Get Volume under Plane
    double tVol = 0.0;
    for(auto& s: set)
      tVol += volumeFactor*(plane - (height[s]+pool[s]));

    //We can partially fill this volume
    if(tVol <= volume && initialplane < plane){

      //Raise water level to plane height
      for(auto& s: set)
        pool[s] = plane - height[s];

      //Adjust Drop Volume
      volume -= tVol;
      tVol = 0.0;
    }

    //Plane was too high and we couldn't fill it
    else fail--;

    //Adjust Planes
    float approach = 0.5;
    initialplane = (plane > initialplane)?plane:initialplane;
    plane += approach*(volume-tVol)/(double)set.size()/volumeFactor;
  }

  //Couldn't place the volume (for some reason)- so ignore this drop.
  if(fail == 0)
    volume = 0.0;

} //End of Flood Algorithm

The height of the plane is adjusted in proportion to the difference in volumes scaled by the surface area of ​​the pool (i.e., by the size of the set). By using the proximity coefficient, you can increase the stability of how the plane reaches the correct water level.

Erosion wrap


The world class contains all three maps in the form of ordinary arrays:

class World {

public:
  void generate();            //Initialize
  void erode(int cycles);     //Erode with N Particles

  //...

  double heightmap[256*256] = {0.0};
  double waterstream[256*256] = {0.0};
  double waterpool[256*256] = {0.0};

};

Note: The height map is initialized using Perlin noise.

Each hydrological step for an individual particle consists of the following:

//...

//Spawn Particle
glm::vec2 newpos = glm::vec2(rand()%(int)dim.x, rand()%(int)dim.y);
Drop drop(newpos);

int spill = 5;
while(drop.volume > drop.minVol && spill != 0){

  drop.descend(heightmap, waterstream, waterpool, track, dim, scale);

  if(drop.volume > drop.minVol)
    drop.flood(heightmap, waterpool, dim);

  spill--;
}

//...

The spill parameter determines how many times a particle can enter the pool and leave it again before it is simply destroyed. Otherwise, the particles die when their volume is depleted.

Note: particles rarely enter the pools and leave them more than one or two times before they completely evaporate during the descent stages, but I added this just in case.

The erosion function wraps this code and performs hydrological steps for N particles, directly changing the flow map:

void World::erode(int N){

  //Track the Movement of all Particles
  bool track[dim.x*dim.y] = {false};

  //Simulate N Particles
  for(int i = 0; i < N; i++){
   
    //... simulate individual particle

  }

  //Update Path
  double lrate = 0.01;  //Adaptation Rate
  for(int i = 0; i < dim.x*dim.y; i++)
    waterstream[i] = (1.0-lrate)*waterstream[i] + lrate*((track[i])?1.0:0.0);

}

Here the track array is passed to the descent function. I found that simultaneously tracking the movement of several particles and corresponding changes provide improved results for the flow map. The adaptation rate determines how quickly old information is deleted.

Trees


Just for fun, I added trees to see if the erosion simulation could be further improved. They are stored in the world classroom as a vector.

Trees are randomly created on the map in places where there are no pools and strong streams, and the terrain is not very steep. They also have a chance to create additional trees around themselves.

In the process of creating trees, they write to the density map of vegetation in a certain radius around them. High vegetation density reduces mass transfer between descending particles and topography. This is to simulate how the roots hold the soil in place.

//... descend function
double effD = depositionRate*max(0.0, 1.0-treedensity[ind]);
//...

Trees die if they are in the pool, or the stream below them is too powerful. In addition, they have a random probability of death.


Thanks to shading and normal maps, even very simple tree sprites make the relief more beautiful.

Note: the tree model can be found in the file "vegetation.h" and in the function "World :: grow ()".

Other details


The results are visualized using a home-made OpenGL wrapper, which is laid out [ here ].

results


Particles can be created on the map according to any distribution you need. In my demos, I created them with a uniform distribution on 256 × 256 cards.

As can be seen below, the resulting simulation is highly dependent on the choice of the original relief. The system is able to simulate a large number of natural phenomena. I could not adequately get only canyons. They may need a very long and slow simulation.

Other phenomena can be observed in the system, such as waterfalls, tortuosity and river deltas, lakes, soil swelling, and so on.

The system is also very good at distributing flows and pools in places where a lot of rain accumulates, and not in random places. Therefore, the generated hydrology is closely related to the terrain.


Real-time hydrological simulation on a 256 × 256 grid. The original relief is relatively smooth, which allows streams to appear quickly. At the very beginning, one can observe the simplest creation of pools and leaks, after which large flows appear and remain.

Comparison of the effects of narrowing flows


To compare the difference created by linking a map to an erosion system, you can simulate hydrology on the same map, including and disabling various effects.

I simulated the same terrain three times:

  • Particle-based erosion (basic erosion) that receives flow and pool maps. Pools still affect generation
  • Basic erosion with parameters changed by hydrological maps (in combination with erosion)
  • Combined erosion with parameters changed by hydrological maps and affecting erosion by trees

Note: this is a rather chaotic system, and the type of hydrology that appears is highly dependent on the terrain. It is difficult to find a "very revealing" example of a relief.


Relief rendering of the base system


Combined system elevation render


Render of the topography of a system with trees

An interesting observation: the combination of hydrological maps with particle parameters actually narrows the river beds. Particularly in planar regions, particles are less distributed and merge into a small amount of stronger flows.

Reduced friction and evaporation successfully cope with the fact that particles begin to prefer already existing channels.

Other effects are more visible with direct observation of the relief.


Hydrological map of the base system


Hydrological map of the combined system


Hydrological map of the system with trees

Note: these results were generated in exactly 60 seconds of the simulation time.

Trees additionally affect narrowing. They enhance the process of cutting out clear paths in steep areas. They force the streams to remain in the already laid channels, and therefore reduce the tortuosity. Thus, the location of trees affects the movement of water.


An example of recording how the location of trees can help maintain the location of streams This is the same relief as before, with all activated effects.

Pool Impact


The system for creating pools works well and allows several water bodies with different heights to exist on the same relief. They can also spill out into one another and empty out.


An example of a video of the formation of pools on a rougher baseline relief with a large elevation difference. The upper lake is physically located above the lower one and discharges the resulting water directly into the lower lake.

Note: I got several seeds, in which three lakes flowed into each other sequentially, but I did not want to spend a lot of time finding the right one for this article. I already generated too many pictures and videos.

From time to time, the height level of the pool jumps. I think this happens when the water level is close to the level of leakage and a lot of water is added. This effect can be reduced by reducing the leakage rate in the flooding function.


After another minute of generation, several new pools appeared on their own.


At a steeper angle, the difference in basin heights is more noticeable.


The hydrological map clearly shows that the central basin merges into the lower basin.

Execution of the flooding algorithm leads to the fact that the pools see a "wall" at the border of the map. This is noticeable in the images shown above.

Another possible improvement could be the addition of sea level to the world so that the pools observe leaks at the edges of the map at sea level, otherwise they simply overflow.

Simulation speed


The time of each step of descent and flooding varies from particle to particle, but about one order of magnitude remains (about 1 microsecond). With steady flows, particles move around the map faster.

Flooding time varies in proportion to the size of the pool, because the pouring operation is the most costly step. The larger the pools, the larger the area for which you need to raise the water level. Large pools are definitely the bottleneck of the system. If you have ideas for increasing speed, let me know.

The descent time varies in proportion to the size of the map and many other parameters, including friction and evaporation rate.

All videos in this article are recorded in real time, that is, in general, the simulation is fast.

Note: shortly after the publication of this article, I made a small change in the method of calculating surface normals, which increased the simulation speed. The effect of this is very noticeable during the simulation, but it is difficult to benchmark it due to large variations in the time it takes to launch and flood. According to my estimates, the simulation speed has doubled.

More beautiful videos



Hydrology simulation on uneven vertical terrain.


A little smoother relief. Some lakes turn out a little fluctuating a little.

Hydrology simulation on a flat smooth terrain.

Subsequent videos were recorded after speed improvement.


Even flatter and smoother terrain.


Video with a river formed from several connected streams. The main river has two points at which it incorporates smaller flows, and we see how it grows in size.


A more uneven relief with the formation of rivers.

I can go on forever.

Conclusion and work for the future


This simple technique is still particle based, but it successfully manages to convey many additional effects. It provides an easy way to simulate large-scale water movement without fully simulating fluid dynamics. In addition, she works with an intuitive water model.

In the simulations, we can observe the appearance of winding rivers, waterfalls, lakes and transfusions of lakes, rivers that divide into flat areas into deltas, etc.

It would be interesting to add different types of soil in the form of a soil map from which erosion parameters could be taken.

You can also easily change the tree system to create different types of trees that hold the soil in place.

It is difficult to convey the variety of generated results in several pictures and videos, so you should try to do it yourself, it turns out very interesting. I recommend experimenting with the original map parameters, including octave noise and map scale.

If you have questions or comments, you can contact me. I worked quite hard on this project, so my brains were sintering and I'm sure I missed some interesting aspects.

All Articles