Sampler Configuration for Crisp Vector Tile Labels

Label text on a map is the one thing users read rather than look at, and it is drawn from a texture like everything else — a glyph atlas sampled per fragment. The sampler settings that make imagery look right make labels look soft, and the settings that make labels crisp make imagery alias. A production map therefore needs at least three samplers, not one, and knowing which is which is the whole of this page. It covers the signed-distance-field glyph case, the imagery case, the data-tile case where filtering is actively wrong, and what anisotropy buys on a tilted camera. It is one stage of texture and tile atlas management in WebGPU.

Three samplers, three jobs A table of sampler settings for three kinds of map texture. Signed-distance-field label glyphs use linear magnification, minification and mipmap filtering with anisotropy of one. Imagery uses the same three linear filters plus anisotropy between four and sixteen. Categorical data tiles use nearest for all three filters and anisotropy of one, because interpolating a class identifier produces a class that is not in the data. All three clamp to the edge rather than repeating. ONE MAP · THREE SAMPLERS Labels (SDF) Imagery Data tiles magFilter linear linear nearest minFilter linear linear nearest mipmapFilter linear linear nearest maxAnisotropy 1 4–16 1 addressMode clamp clamp clamp Samplers are immutable and cheap — create all three once and never think about them again.
The data column is the one that surprises people. Every instinct says smoother is better, and for a raster of class identifiers every form of smoothing invents values the source never contained.

Runnable reference implementation

Three samplers, created once at start-up and reused for the session. Samplers are immutable and cheap, and creating them per frame is a common and entirely avoidable source of driver-side work.

typescript
// Created once. A sampler is immutable — there is never a reason to make
// another with the same settings.
const samplers = {
  // Labels: SDF glyphs. Linear magnification is what makes the distance
  // field smooth; the shader thresholds it back to a hard edge.
  glyph: device.createSampler({
    label: "sdf-glyph",
    magFilter: "linear",
    minFilter: "linear",
    mipmapFilter: "linear",
    addressModeU: "clamp-to-edge",
    addressModeV: "clamp-to-edge",
  }),

  // Imagery: the same filters plus anisotropy, which only has an effect
  // when min and mip filters are both linear.
  imagery: device.createSampler({
    label: "basemap-imagery",
    magFilter: "linear",
    minFilter: "linear",
    mipmapFilter: "linear",
    addressModeU: "clamp-to-edge",
    addressModeV: "clamp-to-edge",
    maxAnisotropy: 8,
  }),

  // Data tiles: no interpolation anywhere. Averaging a class id or a
  // packed integer produces a value that was never in the data.
  data: device.createSampler({
    label: "categorical-data",
    magFilter: "nearest",
    minFilter: "nearest",
    mipmapFilter: "nearest",
    addressModeU: "clamp-to-edge",
    addressModeV: "clamp-to-edge",
  }),
};

The glyph fragment shader is where the distance field earns its keep. The texture stores distance from the glyph edge rather than coverage, the linear sampler interpolates that distance smoothly, and a smoothstep around the threshold turns it back into a crisp edge at any scale.

wgsl
@group(0) @binding(0) var glyph_sampler : sampler;
@group(0) @binding(1) var glyph_atlas   : texture_2d<f32>;

@fragment
fn fs(@location(0) uv : vec2<f32>) -> @location(0) vec4<f32> {
  let distance = textureSample(glyph_atlas, glyph_sampler, uv).r;
  // Screen-space derivative gives the width of one pixel in field units,
  // so the edge stays one pixel wide at every zoom.
  let w = fwidth(distance);
  let alpha = smoothstep(0.5 - w, 0.5 + w, distance);
  return vec4<f32>(1.0, 1.0, 1.0, alpha);
}

fwidth is what makes this scale-independent. Without it the threshold is a fixed band and the glyph edge softens as the label grows; with it the band tracks the on-screen pixel size, so a label rendered at 11 points and the same label rendered at 40 points both have a one-pixel edge.

Parameter reference

Setting Labels (SDF) Imagery Data tiles
magFilter linear linear nearest
minFilter linear linear nearest
mipmapFilter linear linear nearest
maxAnisotropy 1 4–16 1
addressMode clamp-to-edge clamp-to-edge clamp-to-edge
Mip chain needed for the atlas yes only if drawn small

maxAnisotropy is worth a note because it is silently ignored under some settings: the specification only requires it to have an effect when minFilter and magFilter are both linear. Setting it on the data sampler does nothing, which is fortunate, and setting it on the glyph sampler does nothing useful, since glyph quads face the camera.

How a distance field becomes a crisp edge at any size Three stages. The atlas stores, per texel, the signed distance to the nearest glyph edge rather than a coverage value. The linear sampler interpolates that distance smoothly between texels, which is meaningful because distance is a continuous quantity while coverage is not. The fragment shader then thresholds the interpolated distance with a smoothstep whose width comes from fwidth, the screen-space derivative, so the transition band is always about one screen pixel wide regardless of how large the label is drawn. SDF GLYPH · THREE STAGES 1 Store distance, not coverage one channel, 8 bits distance interpolates meaningfully 2 Sample with linear filtering smooth between texels coverage would not survive this 3 Threshold with fwidth smoothstep(0.5 ± w, d) a one-pixel edge at every zoom A single 8-bit channel serves any font size — that is the storage argument for SDF.
The whole technique rests on the middle step being valid. Interpolating a distance halfway between two texels gives the distance at that point; interpolating a coverage value gives a number with no geometric meaning, which is why bitmap glyph atlases blur when magnified.

What anisotropy actually buys

A basemap under a tilted camera is the case anisotropic filtering exists for. When the ground plane recedes, a screen pixel near the horizon covers a long, thin footprint in texture space — many texels along one axis and few along the other. Standard trilinear filtering picks one mip level for that pixel based on the larger of the two extents, which means the texture is blurred along the axis where it did not need to be. Anisotropic filtering takes several samples along the long axis instead, keeping detail that trilinear throws away.

On a north-up 2D map it does nothing measurable, because every pixel’s footprint is square. On a 3D or pitched map it is the difference between a road that stays legible into the distance and one that dissolves into grey a third of the way up the screen. That makes it a setting to enable per layer rather than globally: the pitched basemap wants it, the overlay of circles drawn as screen-space quads does not.

The cost is bandwidth, not correctness. Each anisotropic sample is an extra texture fetch, so a value of 16 can multiply the sampling cost of a fragment by up to sixteen in the worst case. Values of 4 or 8 capture most of the visible benefit; 16 is worth measuring rather than assuming, and it is exactly the kind of change a per-pass timestamp shows clearly.

Failure modes

  • Labels look soft at large sizes. The SDF threshold is a fixed band rather than one derived from fwidth. The glyph is being magnified and the edge magnifies with it.
  • Labels shimmer when the map moves. The glyph atlas has no mip chain and is being minified. SDF atlases need mips like anything else, though the distance field survives downsampling better than coverage would.
  • A categorical raster shows classes that do not exist. Linear filtering on a data tile, averaging class 3 and class 7 into class 5. Every filter on a data sampler must be nearest.
  • An elevation surface has terraced steps. The opposite problem: nearest on data that genuinely wants interpolation. Decode into an r32float texture and sample that, which needs float32-filterable negotiated at device creation.
  • maxAnisotropy has no visible effect. The sampler’s minFilter or magFilter is nearest, so the setting is ignored. It is not an error and produces no warning.
Anisotropy against fragment cost on a pitched basemap A bar chart of relative fragment sampling cost against the maxAnisotropy setting for a pitched basemap. At anisotropy one the cost is the baseline of 1.0 and detail near the horizon is lost. At four the cost is about 2.3 and most of the visible detail is recovered. At eight the cost is about 3.6 with a small further gain. At sixteen the cost is about 6.1 for a gain most viewers cannot see. RELATIVE FRAGMENT SAMPLING COST Anisotropy 1 1.0× — blurred horizon Anisotropy 4 2.3× — most of the gain Anisotropy 8 3.6× Anisotropy 16 6.1× — diminishing 0 1 2 3 4 5 6 7 × cheap the sweet spot measurable rarely worth it On a north-up 2D map the whole chart is irrelevant — every footprint is square.
The benefit saturates long before the cost does. Four is where most of the visible improvement lives, and anything past eight should be justified by a measurement rather than by the setting sounding better.

Choosing where the three samplers are bound

Three samplers means either three bind groups or one bind group with three sampler bindings, and the choice interacts with how layers are batched.

Binding all three in one group is simplest and costs nothing: a sampler binding is a handful of bytes of descriptor, and a fragment shader that only uses one of them pays nothing for the other two being present. That lets a single bind group layout serve the imagery pass, the label pass and the data pass, which in turn lets those passes share group 0 and avoid invalidating it on a pipeline switch.

The alternative — a group per layer kind — is worth it only when the layers also differ in their other bindings, which they usually do: labels need the glyph atlas and a per-label instance buffer, imagery needs the tile array and a tile index. At that point the sampler travels with the rest of the layer’s bindings anyway, and the question answers itself.

What is not worth doing is creating a sampler per layer instance. Samplers are immutable and comparable by their settings, so two layers with identical settings should share one object; some implementations deduplicate internally, but relying on that wastes descriptor slots on the ones that do not.

Backend / Python interop note

The glyph atlas itself is usually generated offline, and two properties of that generation decide how well the sampler settings above work.

The first is the distance range. A signed distance field stores distance from the glyph edge, normalised into the 0–1 range of an 8-bit channel, and the range chosen at generation time decides how far from the edge the field stays meaningful. Too narrow and effects that read the field away from the edge — halos, outlines, the wide strokes used for label casing against a busy basemap — clamp and produce hard artefacts. A range of about 8 pixels at the atlas’s own resolution is a common default and supports a casing several pixels wide.

The second is padding between glyphs. Because the sampler interpolates, a fragment near the edge of one glyph’s cell reads texels from its neighbour, and a glyph atlas packed tightly shows fragments of adjacent letters at the edges of a label. Padding of at least the distance range, plus one texel for the interpolation itself, removes it. The same rule as tile gutters, for the same reason, and the same reason array layers avoid the problem for tiles: interpolation does not respect boundaries the packer invented.

Both are decided when the atlas is built, which for most stacks means a Python or Node tool run at build time rather than anything the browser can adjust. Recording the distance range in the atlas metadata, and having the shader read it rather than hard-coding 0.5, is what keeps the two ends agreeing when someone regenerates the atlas with different settings.