November 5th, 2026

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.

A pixel art sand island split down the middle, flat blue water on the left, and shallows, white foam and waves around the coast on the right

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.

The sand island with its distance field drawn around it as yellow to blue contour rings, one ring every 5 pixels, bending into the cove between the islands

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.

The sand island split down the middle, the left half drawn with its tiles on flat blue water, the right half as a black and white land mask

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:

A cove between two sand islands with four bands of blue water, lightest next to the sand
Depth bands
The same cove with a thin white foam line along the sand
Plus the foam line
The same cove with light wave lines in the water that follow the shape of the coast
Plus the waves
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:

Pixel art island coast where every water band repeats the stair-stepped coastline exactly, like an outline
Raw distance, the bands trace every step
The same coast with wavy water bands and uneven foam
With about 3 px of noise added

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:

Island coast where the water bands and foam are square blocks on the tile grid, overlapping the sand edge

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