Loading interactive article…
Realtime GPU Isolines with Labels
By Kaimana Cook
Building a library for visualizing and animating weather data meant figuring out what to do about isolines. They’re typically generated as static line geometry for a particular data frame. Mapbox chooses candidate label positions along those lines, then uses collision checks to decide which labels to show as the view changes.
Animating between two sets of contour-line geometry means figuring out which lines correspond, matching their vertices, and moving those vertices between frames. But contours don’t keep the same shape or topology as the data changes. A line can split, two lines can merge, and entire contours can appear or disappear.
We can avoid these problems by interpolating the underlying data and drawing the contour lines every frame in a fragment shader. But that leaves Mapbox with no line geometry to place labels along, so we need to handle label placement ourselves.
The input can be any gridded scalar field, such as pressure, temperature, or elevation. The demos use three frames of mean sea-level pressure from WM6, our weather model.
Blurring the data
Small changes between neighboring grid cells can turn into jagged contours and tiny loops. We blur the pressure data before drawing the lines to remove some of that small-scale detail.
Each cell becomes a weighted average of its neighbors. A Gaussian gives more weight to nearby cells and less to distant ones. Sigma sets the width of that Gaussian in data-grid cells: larger values smooth over a wider area.
We do this on the GPU in two passes, first across the rows, then down the columns. That gives the same result as a two-dimensional Gaussian with much less work. The contours and their labels follow this same smoothed field.
Extrema detection
We find low- and high-pressure centers by searching the blurred data for local minima and maxima. We do this once per forecast frame on the CPU and cache the results.
Scan the blurred field
We first find the minimum and maximum within a horizontal window around each cell. We then do the same vertically over those results, giving us the minimum and maximum of the full rectangle. A cell that matches either becomes a candidate low or high. Completely flat neighborhoods are skipped.
Remove nearby duplicates
We process the lowest lows and highest highs first. A candidate within five degrees of an already accepted center of the same kind is dropped. This keeps one pressure system from getting several markers.
Recover the pressure value
Blurring softens pressure highs and lows. To label them with their unblurred values, we search the unblurred data within one degree of each detected grid point and take the lowest pressure for a low, or the highest for a high.
Place the markers
The remaining centers get their pressure values and H or L labels. Highs are blue and lows are red.
Hermite Interpolation
The shader needs a pressure value between grid points to draw each pixel. Bilinear interpolation (linear in each direction) is cheap, but its slope can change abruptly at cell boundaries. You can see those breaks in the bilinear view: corners in the contours and creases in the shading.
We use Hermite interpolation to avoid those breaks. It matches both the values and slopes across cell boundaries, keeping the contours smooth. That takes more work than linear interpolation, but gives us a much better result from the same data.
Getting the derivatives
Hermite interpolation needs three derivatives at each grid point: x, y and mixed xy. To interpolate within a cell, the shader needs these derivatives at all four corners. We have two ways to calculate them from the blurred pressure data.
We can precompute the derivatives and store them in VRAM alongside each pressure value. For each source frame, the shader reads a 2 × 2 neighborhood, with each texel holding the pressure and its three derivatives.
Or we can store just the pressure values and calculate the derivatives in the shader. That requires reading a 4 × 4 neighborhood around the cell, then repeating the calculation for every fragment, every rendered frame. Both approaches produce the same Hermite surface.
Precomputing stores four values per grid point instead of one: pressure and three derivatives. That uses four times the source texture memory for every frame we preload, even though time interpolation only samples two frames at once.
Benchmarking showed roughly 15% less total GPU time for the contour layer when using precomputed derivatives.
Both paths read sixteen scalar components per source-frame sample: sixteen pressure values, or four texels containing pressure and derivatives. Precomputing saves derivative calculations and texture-read instructions. Neighboring fragments can also reuse cached texels, so read counts alone don’t predict GPU time.
Label placement
A moving camera and animated contour lines mean we need to place labels every frame. We use a fragment shader to make this fast and parallel, splitting the view into screen-space grid cells. Each cell gets one shader fragment that looks for a place to put a label.
Here’s that process slowed down so we can see what each cell is doing. All the cells work through these steps at the same time.
Start with a grid
If the cell has a successful placement from the previous frame, we start there and keep the same contour value. Otherwise, we start at the cell’s center and try to label the nearest contour. Reusing the previous placement gives labels continuity from one frame to the next.
Move onto the contour
The starting point probably isn’t exactly on that contour. We use the field’s value and gradient to take four Newton steps toward it. If we can’t get close enough, leave valid data, or move too far from the cell, that cell proposes no label this frame.
Follow the line both ways
Once we’re on the contour, we follow it in both directions to see whether there’s room for the text. Each step moves along the line’s tangent, then corrects back onto the contour. If the line bends too much or we can’t trace far enough, we reject the position.
Fit the text
We use the two endpoints to set the label’s angle, then check that its bounding box fits inside the view. The box on the canvas shows where the text would go.
Compare the neighbors
Now a second shader pass checks the proposed labels against their neighbors. We don’t want overlapping text or the same value repeated too close together. Labels overlapping an H or L marker are dropped. A fixed priority resolves conflicts between contour labels, with existing labels getting preference to reduce flicker.
Draw the labels
We draw the text on the GPU with an MTSDF font atlas and leave gaps in the contours underneath. Accepted positions stay in a texture for the next frame.
| Valid time (UTC) | Forecast lead | Global minimum (hPa) | Global maximum (hPa) |
|---|---|---|---|
| 2026-09-05T00:00:00.000Z | +0 hours | 949.47 | 1036.43 |
| 2026-09-05T12:00:00.000Z | +12 hours | 951.32 | 1041.96 |
| 2026-09-06T00:00:00.000Z | +24 hours | 943.78 | 1038.54 |