Paint some land above and the coastline lights up with foam, the shallows gradient out to deep water, everything wraps the shore neatly no matter what shape you draw. To do that, the water shader has to answer one question for every single pixel on screen: how far is the nearest coast? Get that number and foam is just "close to shore," shallows are "a bit further," deep water is "far." The whole look hangs on one distance.
The obvious way to get it is a catastrophe. This article is about the clever way, the jump flood algorithm, how it turns that question into a handful of GPU passes, the several effects one answer drives, and the surprising amount of work it took to stop the coast from flickering.
One Number, Many Effects
What the water actually wants is a distance field: a texture where every texel stores its distance to the nearest coastline. Once you have that field, a whole pile of effects fall out of reading it and thresholding:
- Shore foam is the band where distance is small. A soft white skirt hugging every coast, whatever its shape.
- Shallow water is a gradient on the same distance, pale near the beach fading to deep blue offshore.
- Fog of war uses it as a land mask: keep the map clear over land and near the coast, fog the open sea. In Privateer, my hex naval game, the fog shader samples this exact field so the clearing traces the real coastline instead of a blocky hex outline.
Make the field better and every one of those improves at once. That's the payoff of a distance field: it is a reusable answer, computed once, read by everything. The only problem is computing it.
The Naive Cost
Here is the version that will melt your GPU. For each pixel, loop over every coastline pixel and keep the closest one. On a 1024 by 1024 texture that's about a million pixels, and a decent island map has a coastline thousands of texels long. A million pixels times thousands of coast texels is billions of distance checks, per regeneration. You can feel a number like that from across the room.
You could shrink it with acceleration structures, but this is a texture on the GPU, not a scene of objects. What you want is something that thinks in texels and passes, and parallelises across every pixel at once.
Jump Flooding
The jump flood algorithm computes that field in roughly log of the texture width passes. For a 1024 texture that's about eleven passes total, each one a cheap nine tap sample per pixel. Billions of checks become eleven passes. It works in three moves.
Seed. Every coastline texel writes its own coordinate into a buffer. Every other texel writes "nothing yet." That's the starting knowledge: only the coast knows where the coast is.
// Since reading outside the texture returns zero, shift all coords by (1,1)
// so that (0,0) can mean "no seed here".
int2 shiftedId = id + int2( 1, 1 );
_FloodTex[ id ] = isInsideSeed ? int4( shiftedId, 0, 0 ) : int4( 0, 0, shiftedId );
Flood. This is the jump. Start with a big step, half the texture width, and every pixel samples nine neighbours at plus and minus that step in x and y. Of the seed coordinates those neighbours are carrying, it keeps the closest one. Then halve the step and repeat: 512, 256, 128, all the way down to 1. Early passes let information leap clear across the texture in a single hop, later passes refine locally. The C# side is just that halving loop driving the compute shader:
int stepMax = (int) Mathf.Log( Mathf.NextPowerOfTwo( sizeMax ), 2 );
for( int n = stepMax; n >= 0; n-- ) {
int stepSize = n > 0 ? (int) Mathf.Pow( 2, n ) : 1;
_computeShader.SetInt( ShaderIDs.stepSize, stepSize );
_computeShader.SetTexture( _floodKernel, ShaderIDs.floodTexRead, read );
_computeShader.SetTexture( _floodKernel, ShaderIDs.floodTex, write );
_computeShader.Dispatch( _floodKernel, resolution.x, resolution.y, 1 );
( read, write ) = ( write, read ); // ping-pong
}
The visualiser at the top does exactly this. Hit Replay and watch the coloured regions leap outward in big jumps and then tighten up, each seed claiming the pixels it is nearest to. What you are watching is a Voronoi diagram assembling itself, because "nearest seed per pixel" is precisely a Voronoi diagram. The distance field is one subtraction away.
Distance. After the flood, every pixel holds the coordinate of its nearest coast texel. Subtract, take the length, and that's the distance. The one extra trick that makes it a proper signed distance field is running two floods at once, one for seeds inside the land and one for seeds in the sea, packed into the same four channel buffer. Subtract inner distance from outer and the sign tells you which side of the coast you are on.
float outerDist = any( closestCoords.xy ) ? length( diff.xy ) : 0;
float innerDist = any( closestCoords.zw ) ? length( diff.zw ) : 0;
float sd = outerDist - innerDist;
_SDFTex[ id ] = ( sd * 0.5 ) + 0.5; // 0.5 = coastline, < 0.5 land, > 0.5 sea
Negative inside land, zero on the coast, positive out at sea. One texture, and the water shader, the shallows, and the fog all read it with a threshold.
Where It Flickered
The algorithm is the easy part. Getting a stable coast out of it in a shipping game was most of the actual work, and every fix started as an ugly shimmer.
The first was a data race. Jump flooding reads a buffer and writes the result, and if it reads and writes the same buffer, one thread reads a texel another thread is mid write on. The result changes between frames for no reason, and the coastline crawls. The fix is a ping pong: two buffers, read one, write the other, swap after each pass.
Read and write the same buffer in one pass and you have a data race. The field changes between frames for no reason, and it shows up as water edge flicker.
The second was subtler and took me longer. The field is generated from a silhouette rendered by a dedicated camera that follows the gameplay camera. As you sail, that camera slides by fractions of a texel, and the hard seed threshold flips edge texels on and off underneath it. Every flipped texel is a seed that appears or vanishes, so the whole coast shimmers while the camera moves. The fix is to snap the silhouette camera to its own texel grid before it renders, so the coast moves in whole texel steps instead of sliding continuously:
float texelX = othrcamera.orthographicSize * 2f * othrcamera.aspect / rt.width;
float texelY = othrcamera.orthographicSize * 2f / rt.height;
Vector3 p = othrcamera.transform.position;
p.x = Mathf.Round( p.x / texelX ) * texelX;
p.y = Mathf.Round( p.y / texelY ) * texelY;
othrcamera.transform.position = p;
The third only showed up once fog of war used the same field. The fog clearing has a visible seam at the edge of the SDF rectangle, and because that rectangle followed the moving camera, the seam slid across the map every turn. So in battle the generator stops chasing the camera entirely: it frames the SDF camera over the whole map once, pins it there, and regenerates the field a single time after the map silhouette is painted. Static map, static field, static seam. A field that never moves cannot shimmer.
The Pattern Is Broader
None of this is really about coastlines. The seed pass just marks "the thing you want to be near," and everything downstream is distance to that thing. Change what you seed and the same three passes solve a different problem.
Seed the letters of a glyph and you get the signed distance field fonts that keep text crisp at any zoom, the same SDF assets sitting in this game's own text rendering. Seed the silhouette of a sprite and the field gives you outlines and glow at any thickness for free. Seed a set of points and the flood is a Voronoi diagram, useful for everything from procedural texturing to influence maps. Seed nothing but obstacles and you have a distance field for steering and pathfinding. It is the same shape every time: mark the seeds, jump flood, read the distance.
The Takeaway
A distance field is a precomputed answer to "where is the nearest thing," and the jump flood algorithm computes it across a whole texture in about a dozen parallel passes instead of the billions of checks the honest version would cost. Once you have it, effects stop being bespoke. Foam, shallows, and fog are not three shaders solving three problems, they are three thresholds on one number.
And the lesson that cost me the most time is the one that never appears in the algorithm write ups. The maths gets you a distance field in an afternoon. Getting that field to sit perfectly still while a camera glides over it, so the coast doesn't crawl and the fog seam doesn't slide, is the part that actually shipped.