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.
The padded canvas
Section titled “The padded canvas”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”.
You do not have to compute it
Section titled “You do not have to compute it”Leave pad out and the renderer works it out. Each node declares how far it
reads past the pixel it writes — blur asks for 3σ, 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"}ezu tile --style soft.json --tile 6/57/25 --out soft.png# INFO pad: 0 declared, 36 needed — rendering with 36When 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.
Why these fields need an upper bound
Section titled “Why these fields need an upper bound”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 forA 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.
What it does and does not cover
Section titled “What it does and does not cover”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 translateemits 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
maxpays for that max on every tile; pinningpadtrades 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.
Neighbour tiles
Section titled “Neighbour tiles”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 labelsr.requestedNeighborOffsets('terrain'); // all eight, unless neighbor-fetch is offThe 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.
Missing neighbours
Section titled “Missing neighbours”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.