A stone drops into water, a ring runs outward, bounces off the edge and mixes with all the others. You can rebuild that with surprisingly little code: two arrays, a loop and a shader that turns the numbers into water.
Click and drag to drop stones, the rain throws in a few on its own. The three chips set the damping to 0.97, 0.99 or 0.999. At 0.97 the rings are gone in the blink of an eye, at 0.999 they keep running to the edge and back for a long time.
Three lines
The surface is a grid of heights, 240 by 135 cells here. There are two buffers: the current height cur and the one from the step before, prev. The new height of a cell is the average of its four neighbours, doubled, minus the height it had before. Damping goes on top:
next = (left + right + up + down) / 2 - prev; next *= damping;
That is the wave equation in a form you can actually remember. A point that sits higher than its neighbours gets pulled down and shoots past the resting level, just like a pendulum. The repo has the long version with a parameter courant2 that is set to 0.5, which makes it the same formula:
const float laplace = up[x] + down[x] + mid[x - 1] + mid[x + 1] - 4.0F * mid[x]; old[x] = (2.0F * mid[x] - old[x] + courant2 * laplace) * damping;
The result is written straight into prev, nobody needs that anymore, and then the two buffers are swapped. The border is never written and stays 0, which is why waves bounce back from it.
A drop adds a cosine bump to cur. prev stays untouched, so the water gets a kick instead of a slowly built-up dent, and that kick becomes the ring.
Simulation on the CPU, water on the GPU
The simulation runs in fixed steps, 120 per second, with the accumulator from Update Loop and the clamp against stalls. Why fixed steps is in Fixed Timestep. For waves there is a second reason, more on that below.
32,400 cells are nothing for the CPU, and this way the simulation can be tested without a graphics card. The picture itself has 518,400 pixels with a lot of math each, and that is shader work. So the demo uploads the height field as a texture every frame:
heights = Texture::create(240, 135, GL_R32F, GL_RED, GL_FLOAT, GL_NEAREST, ...); // every frame heights.update(field.heights().data());
The fragment shader reads the slope from it and makes two things out of it. Where the surface is tilted, the floor below is sampled at a different spot (refraction), and where it faces the light, it shines.
vec2 slope = 0.5 * vec2(height(vUv + vec2(e.x, 0.0)) - height(vUv - vec2(e.x, 0.0)),
height(vUv + vec2(0.0, e.y)) - height(vUv - vec2(0.0, e.y)));
vec3 n = normalize(vec3(-slope * 5.0, 1.0));
vec3 color = floorColor(p + slope * 150.0);
float spec = pow(max(dot(n, normalize(light + vec3(0.0, 0.0, 1.0))), 0.0), 120.0);
The floor is a tile pattern the shader computes itself, there is no image file. The grout lines show the waves best, they bend along with them.
Where it blows up
The 0.5 is the upper limit up to which the method stays stable: a wave must not travel further per step than the grid can resolve. The test shows it in plain numbers. With courant2 = 0.5 the height stays below 2 after 80 steps, with 0.6 it is above 1000. The error grows with every step, and one drop turns into huge numbers.
Two things follow from that. The wave speed is baked into the step size, at 0.5 a wave travels about 0.7 cells per step. If you let the step size grow with the frame time, the water would run fast one moment and slow the next, and one long frame would break the 0.5 limit. Fixed steps solve both. Damping has its own reason. Without it (1.0) the energy stays conserved to 0.1 percent over 300 steps, so the pool would never calm down. With 0.99 per step, a little over an eighth of the energy is left after 200 steps.
Pitfalls
Filtering float textures linearly needs the extension OES_texture_float_linear in WebGL2, and it is not available everywhere. R32F with GL_NEAREST works without an extension, so the shader does the bilinear interpolation itself, four fetches per height, 16 per pixel.
The rings come out slightly squarish. That is the grid, which behaves differently along the diagonals than along the axes. For rounder rings you need more neighbours.
And the mouse: the page scales the canvas with CSS, so the demo converts every event from window size to pixel size and back to 960 by 540. Otherwise the drop lands next to the click.
The code is in the repo under water-ripples-2d. The project Game Dev Snippets collects all demos of the series.