Skip to content

Padding and neighbours

Filters read pixels around the pixel they write. Near a tile edge those pixels are outside the tile. Two mechanisms deal with that: a padded canvas for raster-space filters, and neighbour tiles for data that has to be genuinely continuous.

A style declares pad, and rendering happens on a canvas of tile-size + 2 × pad on each axis, with real geometry drawn into the margin. The margin is cropped off at the end.

{ "name": "mine", "pad": 24, "…": "" }

Every Raster and ScalarField in the graph is this padded size, which is why those kinds are described as “padded canvas-sized”.

Leave pad out and the renderer works it out. Each node declares how far it reads past the pixel it writes — blur asks for , mosaic for a block, a text layer for the extent a label may cross a border by — and those distances depend only on fields with a static bound, which is exactly why the answer is knowable before anything renders:

{
"name": "soft",
"nodes": {
"f": { "op": "features", "layer": "earth" },
"fill": { "op": "fill-solid", "features": "@f", "fill": "#c2410c" },
"out": { "op": "blur", "input": "@fill", "sigma": 12 }
},
"output": "@out"
}
Terminal window
ezu tile --style soft.json --tile 6/57/25 --out soft.png
# INFO pad: 0 declared, 36 needed — rendering with 36

When you do write pad, it is a floor, not the answer: the renderer takes whichever is larger of your number and what the graph reaches. So a style asking for a deliberately generous margin keeps it, and a style asking for too little cannot silently end up with clamped edge pixels at the tile border.

There is a ceiling, MAX_PAD at 256 px. A graph asking for more than that is reported by ezu check and rendered with the style’s own pad instead — a blur with sigma: 200 wants ~600 px of margin, which is a style bug rather than a canvas size.

Padding is fixed before evaluation starts, so a field that decides it has to have a bound the build can see:

"sigma": 3 // ✅ literal
"sigma": "$softness" // ✅ if the param declares `max`
"sigma": "@computed", "sigma-max": 12 // ✅ the ceiling states the bound
"sigma": "@computed" // ❌ nothing to pad for

A literal is its own bound and a $param carries one in its declaration. A computed value — math, zoom, expr — has neither, and cannot be a $param either, so the style states the ceiling itself with a <field>-max sibling. Padding is computed from that, and a value above it is clamped at render time: the canvas cannot grow mid-render, so a larger sigma would read past the margin and take the tile’s edge pixels with it. Clamping is logged once per node, since quietly weakening a filter the style asked for is worth knowing about.

Seven fields work this way — blur’s sigma, fill-solid’s blur-sigma, warp and displace’s amp-x-px / amp-y-px, mosaic’s block, erode/dilate’s radius-px, and density’s radius — each with a matching -max.

"z": { "op": "zoom" },
"s": { "op": "math", "fn": "mul", "a": "@z", "b": 1.5 },
"out": { "op": "blur", "input": "@c", "sigma": "@s", "sigma-max": 12 }

That pads for 36 px (3 × 12) at every zoom, and blurs by 1.5 × zoom up to that.

The rule is reading, not painting. An op that samples pixels around the one it writes declares how far — blur, warp, displace, mosaic, erode/dilate, sharpen, edge-detect, density, a fill-solid’s own blur, and the extent a text layer reserves for labels crossing a border. Those are covered.

How far an op paints past its geometry is not: a stroke’s width-px, a fill-dabs radius, a stamp’s sprite. A 24 px stroke running just outside the tile still colours pixels inside it, and with no margin those pixels come out wrong — but a width may be a width-expr evaluated per feature, which has no value until the tile renders, so there is nothing to compute in advance.

So write pad when:

  • You paint wide. Half your widest stroke or dab radius is the floor. This is why ezu translate emits a pad: MapLibre line widths are usually expressions.
  • You want less than the worst case. A blur sigma that is a param with a generous max pays for that max on every tile; pinning pad trades edge quality at the border for pixels.

Otherwise leave it out. ezu check prints what the graph needs either way, so you can see what you are choosing between.

Padding solves raster-space filters, because the geometry needed for the margin is fetched with the tile. Two things need more than that:

  • DEM and raster imagery. A hillshade computed from a single DEM tile steps at the border, because the gradient at the edge needs the neighbour’s elevations. The host stitches the 3×3 neighbourhood into one per-tile field before evaluation.
  • Label collision. Candidates from the eight neighbouring tiles participate in placement, so both sides of a border agree — see tiles and determinism.

Nothing else reads neighbours. In a wasm host, ask rather than assume:

r.requestedNeighborOffsets('basemap'); // [] for a style with no cross-tile labels
r.requestedNeighborOffsets('terrain'); // all eight, unless neighbor-fetch is off

The answers differ because the two cases differ. A vector neighbour supplies candidates, and only a node that asked for them uses any, so the graph decides and the list is usually empty. A DEM or raster neighbour supplies pixels for one stitched buffer: the source’s neighbor-fetch decides, it is on by default, and a neighbour left unbound is not absent data but wrong data — the stitch fills that pad by clamping the centre tile’s own edge, and every filter reading the pad carries the guess back into the tile. That is a seam of a few pixels along the border, brightest where the graph amplifies (a glow, a levels lift), and it renders without an error.

Native hosts get the same answer from ezu::paint::host::source_neighbor_offsets. Its sibling requested_neighbor_offsets answers the narrower question — what the graph named — so it reports nothing for a DEM, which binds under a bare source name that no node can suffix with @dx,dy. Ask the source-aware one unless you know you want the other.

A neighbour that does not exist is normal at the edge of coverage. Raster and DEM sources take an on-missing policy — empty (default), upsample, or error — and requests past a source’s max-zoom always upsample from the max-zoom ancestor. See sources and assets.

Map renders on this site are made fromOpenStreetMap data viaProtomaps (© OpenStreetMap contributors), elevation from Re:Earth Terrain,Mapterhorn andEGM2008 (NGA), and aerial imagery from GSI Japan(© 国土地理院). The painterly styles use CC0 brushes byDavid Revoy.