2D water shorelines with a distance field
Shallows, foam and waves that follow every bay of a 2D tile coastline, from one distance map baked at load. With a bake script, a Godot shader and pitfalls.

Tile land on flat blue water looks pasted on. Shore tiles help, but they give every edge the same fixed strip. A distance field gives you shallows that fill the coves, a foam line that hugs every corner, and waves that roll in toward the coast. All of it comes from one number per water pixel, the distance to the nearest land.
Same island and tiles on both sides. The left half is one flat color. The right half colors each water pixel by how far it is from land, and halfway through the loop it shows the distance map itself.
What the distance field is
For every water pixel, store how far it is to the nearest land pixel. The numbers are small near the coast and grow out to sea, and they follow every bay and corner on the way.
Each ring is 5 px further from land than the one inside it. They wrap around the cove and the small islands with no extra work.
You compute the distance map once, when the level loads. After that the water is all lookups: a color per distance band for the shallows, foam where the distance is under about 2 px, and waves on the rings where distance plus time lines up with the wave spacing.
Bake the map
You need the land as pixels, at your art's resolution. Draw the land layer once into an image with the water left transparent. The island here is a 16 px top-down sand tileset generated with SpriteCook and placed on a dual grid. Its tiles have transparent edges, so the land mask is just their alpha.
In Python, scipy does the distance in one call:
import numpy as np
from PIL import Image
from scipy import ndimage
MAX_DIST = 32 # px; water further out than this is all deep
land = np.array(Image.open("island.png"))[..., 3] > 0 # RGBA land layer, alpha > 0 is land
d = ndimage.distance_transform_edt(~land) # px to the nearest land pixel
noise = ndimage.gaussian_filter(np.random.default_rng(1).random(land.shape), 8)
d += (noise - noise.mean()) / noise.std() * 2 # about +-3 px of smooth wobble
img = np.clip(d / MAX_DIST * 255, 0, 255).astype(np.uint8)
Image.fromarray(img).save("shore_distance.png") # black at the coast, white 32 px out
The result is a grayscale PNG the size of your map. 8 bits over 32 px is a step of 1/8 px, which is plenty for pixel art. The noise is there so the bands wobble like water instead of tracing the coast exactly (more on that in the pitfalls).
Baking at runtime in Godot
For maps you generate in game, bake at load instead. This is the same bake in GDScript. The distance part is an exact transform that matches scipy on every pixel of this map, and Godot's FastNoiseLite does the wobble. On a desktop PC it takes about 100 ms for this 360 x 322 map.
# shore_distance.gd: bakes the distance map for the water shader at load time.
# Use: $Water.texture = ShoreDistance.bake(land_image)
class_name ShoreDistance
extends RefCounted
const MAX_DIST := 32.0 # px, the same max_dist as in the shader
# land: your land layer as an Image, transparent where the water is.
static func bake(land: Image) -> ImageTexture:
var w := land.get_width()
var h := land.get_height()
var dist := distance(land)
var noise := FastNoiseLite.new()
noise.fractal_type = FastNoiseLite.FRACTAL_NONE
noise.frequency = 0.03
var img := Image.create(w, h, false, Image.FORMAT_L8)
for y in h:
for x in w:
var d := dist[y * w + x] + noise.get_noise_2d(x, y) * 4.0 # the wobble
var v := clampf(d / MAX_DIST, 0.0, 1.0)
img.set_pixel(x, y, Color(v, v, v))
return ImageTexture.create_from_image(img)
# px from every pixel to the nearest land pixel, like scipy's distance_transform_edt.
static func distance(land: Image) -> PackedFloat32Array:
var w := land.get_width()
var h := land.get_height()
var d := PackedFloat32Array()
d.resize(w * h)
for y in h:
for x in w:
d[y * w + x] = 0.0 if land.get_pixel(x, y).a > 0.0 else 1e20
for x in w:
_pass(d, x, w, h) # squared distance down each column
for y in h:
_pass(d, y * w, 1, w) # then along each row
for i in d.size():
d[i] = sqrt(d[i])
return d
# Exact 1D squared distance over n values, `step` apart from `start`
# (Felzenszwalb and Huttenlocher's lower envelope of parabolas).
static func _pass(d: PackedFloat32Array, start: int, step: int, n: int) -> void:
var f := PackedFloat32Array()
f.resize(n)
for i in n:
f[i] = d[start + i * step]
var v := PackedInt32Array() # the parabolas in the envelope
v.resize(n)
var z := PackedFloat32Array() # where each one takes over
z.resize(n + 1)
var k := 0
z[0] = -INF
z[1] = INF
for q in range(1, n):
var s := _meet(f, q, v[k])
while s <= z[k]:
k -= 1
s = _meet(f, q, v[k])
k += 1
v[k] = q
z[k] = s
z[k + 1] = INF
k = 0
for q in n:
while z[k + 1] < q:
k += 1
d[start + q * step] = (q - v[k]) * (q - v[k]) + f[v[k]]
static func _meet(f: PackedFloat32Array, q: int, p: int) -> float:
return ((f[q] + q * q) - (f[p] + p * p)) / (2.0 * (q - p))
The algorithm is from Distance Transforms of Sampled Functions by Felzenszwalb and Huttenlocher. It runs once down every column and once along every row, so the time grows with the map's area. For a map built from a TileMapLayer, one way to get the land Image is to draw that layer once in a SubViewport with a transparent background and call get_texture().get_image().
Color the water in a shader
The shader reads the distance and picks a color, in three layers:
shader_type canvas_item;
// Put this on a Sprite2D whose texture is the baked distance map, drawn under the land.
// Texture filter: Nearest, so every band stays on the pixel grid.
uniform float max_dist = 32.0; // the MAX_DIST the map was baked with
uniform vec4 deep : source_color = vec4(0.110, 0.290, 0.502, 1.0);
uniform vec4 mid : source_color = vec4(0.133, 0.384, 0.588, 1.0);
uniform vec4 shallow : source_color = vec4(0.204, 0.549, 0.690, 1.0);
uniform vec4 shoal : source_color = vec4(0.376, 0.745, 0.769, 1.0);
uniform vec4 wave : source_color = vec4(0.588, 0.839, 0.855, 1.0);
uniform vec4 foam : source_color = vec4(0.925, 0.965, 0.957, 1.0);
uniform float wave_spacing = 11.0; // px between waves
uniform float wave_speed = 4.4; // px per second, towards the coast
void fragment() {
float d = texture(TEXTURE, UV).r * max_dist; // px to the nearest land
vec4 c = deep;
if (d < 26.0) c = mid;
if (d < 13.0) c = shallow;
if (d < 5.0) c = shoal;
float phase = mod(d + TIME * wave_speed, wave_spacing);
if (phase < 1.6 && d > 4.0 && d < 20.0) c = wave;
if (d < 2.2 + 1.1 * sin(TIME * 2.5)) c = foam; // breathes in and out
COLOR = c;
}
- Depth bands. Shoal under 5 px, shallow under 13, mid under 26, deep beyond that. Bigger numbers give a wider shelf.
- Foam. Everything under about 2 px. The sine moves that edge out and back about every 2.5 seconds, so the foam breathes.
- Waves.
mod(d + TIME * wave_speed, wave_spacing)is small on rings 11 px apart, and adding time slides those rings toward the coast. They only draw between 4 and 20 px out, so they start offshore and stop before the foam.
In Godot, put the shader on a Sprite2D with the baked map as its texture, under your tilemap, with the texture filter on Nearest. The shader and the GDScript bake above run as they are in Godot 4.6, and with the Python-baked map the shader's frames match the images in this post pixel for pixel. In Unity the same math works in an unlit sprite shader or Shader Graph, with the map's filter mode set to Point.
Common pitfalls
No wobble, and the water copies your tiles. Without the noise, every band is an exact copy of the coastline, stair steps and all, and the water reads as an outline effect:
Distance per tile instead of per pixel. It's tempting to compute the distance on the tile grid, since that's what the map is made of. Scaled up to pixels, the bands turn into blocks that ignore where the sand is drawn:
Smooth filtering at screen resolution. If your game draws at screen resolution (Godot's canvas_items stretch mode, or a zoomed camera), a linearly filtered map gets read between its pixels. The bands then come out as thin smooth curves that don't match your art. Keep the map at the art's resolution and sample it with Nearest.
Coasts that change. If the player digs a channel or builds land, the stored map can only change within MAX_DIST of the edit. Recompute a window around the edit with twice MAX_DIST of margin, and copy back the part within MAX_DIST of it.
Chunked worlds. Bake each chunk with MAX_DIST of its neighbors' land around it. Otherwise the bands won't line up across chunk borders.
Where to next
- Dual-grid tilesets, explained, how the island's tiles are placed
- Make a top-down tileset that actually tiles, for the land itself
- Wang tiles for pixel art, explained, so big stretches of sand or grass don't show a grid
- Generate a tileset from one prompt, with Godot, Unity, and Tiled exports