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.
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.
// 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.
@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.
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:
neareston data that genuinely wants interpolation. Decode into anr32floattexture and sample that, which needsfloat32-filterablenegotiated at device creation. maxAnisotropyhas no visible effect. The sampler’sminFilterormagFilterisnearest, so the setting is ignored. It is not an error and produces no warning.
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.
Related
- Texture and tile atlas management in WebGPU — the topic this page belongs to.
- Generating mipmaps for WebGPU map tiles — the chain the min and mipmap filters read from.
- Uploading raster tiles into a texture_2d_array — how the atlas gets to the GPU.
- Initializing WebGPU devices for GIS workloads — negotiating float32-filterable for elevation.
- VRAM budget management across tile zoom levels — what the atlas costs alongside the tiles.