Maps

One Attribute to Rule Them All

Rethinking symbol rendering across Mapbox SDKs: how a single feature index unlocked per-feature styling at scale, and the foundation behind Appearances.

Prelude

A city-centre tile can carry several hundred symbols at once, labels, icons and shield markers, each potentially styled with its own color, opacity and size. Rendering all those efficiently on the GPU is a resource allocation problem that gets harder the more styling controls you want to provide. The more properties you want to drive per feature, the more GPU budget you consume. Until you hit a hard ceiling.

This post is about how we hit that ceiling, how we broke through it, and what it unlocked.

Chapter One: Running Out of Room

Before this work, every paint property that could vary per feature (text-color, icon-opacity, text-halo-width, …) was stored on its own vertex attribute slot. One property, one slot. WebGL2 only guarantees 16 vertex attribute slots per shader program and symbol rendering already spends five of those on geometry data: position, anchor, texture coordinates, extrusion vectors and projected position for collision.

That's before paint properties enter the picture. Add a data-driven text-color expression, per-feature opacity for highlighted POIs or a halo with data-driven width and each new property competes for what's left.

Figure 1: Vertex attribute slot pressure

Add data-driven paint properties. The left panel (no UBOs) consumes one slot per property until it overflows the 16-slot ceiling. The right panel (with UBOs) holds the line at a single feature index slot — the property count is read from the UBO.

There was a second problem too. Constant paint properties, the ones that are the same for every rendered feature on a given layer, are passed as plain uniforms and are easily updated at runtime. Data-driven properties though, are a different story. Their per-feature values are baked into vertex attributes at tile parse time. Changing them at runtime means re-evaluating and re-uploading vertex buffers for every affected tile, which is a CPU-intensive task that can result in dropped frames.

The vertex attribute model breaks down on two fronts as soon as per-feature styling is used extensively. It runs out of slots as you add properties, and it becomes expensive to update when those properties need to change at runtime. Both called for the same fix.

Chapter Two: One Attribute to Find Them All

The first instinct was to pass per-feature data via textures. They're flexible, widely supported, and performant. Running benchmarks on low-powered mobile devices in our device lab told a different story though, texture sampling was measurably slower than UBO reads on exactly the same hardware. So we landed on UBOs.

A Uniform Buffer Object is a fixed-size GPU memory block that shaders can read from directly. Instead of encoding per-feature properties into vertex attributes that get duplicated for every vertex that flows through the GPU pipeline, you write them into a compact structured buffer, upload it once, and bind it at draw time.

The key mechanism is simple: each vertex carries a single integer attribute, a feature index, that the shader uses to look up its feature's properties from the UBO. Whether a layer has two data-driven properties or all nine, the attribute cost is always one slot.

Figure 2: UBO lookup at draw time

The draw-time lookup, step by step. Each vertex carries a feature index. The shader uses it to read a row from the UBO, and that row's values flow through to the pixels of the rendered POI.

Trading N attribute slots for a single index is not just a way to save space, it's a fundamentally different model where the number of per-feature properties no longer affects the shader's attribute budget.

There's one more layer of plumbing worth surfacing. The per-vertex index doesn't reach into the properties buffer directly, it goes through a small indirection table first, and the value found there addresses the actual block. That extra hop earns three things. Features whose evaluated properties happen to be identical can share a single property block, so the buffer stays compact when a tile has lots of similar labels. An appearance switch updates one entry in the indirection table per feature, instead of the four per-vertex index writes a direct-addressing model would need — an exact 4× cut in the data pushed to the GPU on every change. And the same table is the substrate for the per-feature animation work coming next.

UBOs come with constraints too. The size is fixed at allocation, and OpenGL ES only guarantees 16 KB per UBO. That's the floor across the platforms we ship to. A tile dense enough to exceed that envelope has to be split into several UBOs and several draw calls, which costs GPU state changes. With our per-feature footprint that ceiling lands around 1,000 features per draw when only one or two properties are data-driven, dropping toward ~170 once all nine are. Re-allocating with a different size would also force a shader variant that knows about the new layout, which is one of the things we wanted to avoid. The current default is what testing pushed us toward, and we've kept the door open to switch strategies if measurements ever say otherwise.

Chapter Three: One Layout, Four Backends

One of the first design decisions we made was that GL JS and GL Native had to agree on the UBO layout completely. Both renderers parse the same tiles, respond to the same style spec, use the same shaders and should generate the same output from all of that. Diverging layouts would mean translating between them or having different implementations, a cost we didn't want to pay.

Both implementations share an identical three-buffer structure per symbol batch:

  • Header buffer: a compact 48-byte descriptor encoding which properties are data-driven, which are zoom-interpolated, and the byte offset of each property within the per-feature data block.
  • Properties buffer: packed per-feature data blocks. Only data-driven properties appear here. Constant properties are passed as plain uniforms, keeping the per-feature block small.
  • Block-indices buffer: an indirection layer between feature indices and property blocks.

Figure 3: UBO memory layout — interactive hex viewer

One schema, N feature instances. The 48-byte header describes the layout once; each feature block then follows that schema. Hover any byte in the header to inspect its field. The indirection layer that maps feature ids into the properties buffer is shown in motion in Figure 4.

This layout is byte-for-byte identical in WebGL2 (GL JS), Metal (iOS and macOS), Vulkan (Android) and OpenGL (legacy Android). The same shader logic reads from the same memory layout regardless of the platform.

Getting there required solving different problems on each side. On the GL Native side, there are three rendering backends to support: Metal, Vulkan and OpenGL, each requiring different API calls to create, upload and bind uniform buffers. GL Native wraps all three behind a graphics abstraction layer so the symbol rendering code works against a single interface. On the GL JS side, tiles are parsed in Web workers to keep the main thread free for rendering. The UBO binder is created and populated there but, since the WebGL context is tied to the map's canvas on the main thread, GPU buffer allocation has to wait until the parsed tile crosses back over to the renderer: the worker produces plain typed arrays in the agreed layout and the main thread is the one that actually creates and uploads the UBOs.

The investment paid off during validation: the same render test, run against WebGL2 on GL JS, Metal on iOS, and Vulkan on Android, produces pixel-identical output. During implementation, the shared suite caught several cases where backends interpreted the layout differently before they could reach users. Every layout fix applied to all four renderers at once.

Chapter Four: A symbol with multiple hats

UBOs are the infrastructure that makes Appearances work.

Appearances let style designers define conditional style variants for a group of features. A POI layer might have a "selected" appearance that changes the icon, increases the text size and shifts the color. An electric vehicle charging station layer might have an icon that changes according to availability. Each appearance has a condition expression that when it's true, instantly swaps all the layout and paint properties to its new value.

Figure 4: Indirection-only appearance switch - when dedup wins

Toggle the Museum's appearance. Both the default and selected paint blocks already exist in the properties buffer (dedup populated them at parse time), so the switch is a single indirection-table write; the properties buffer is never touched.

Without UBOs, per-feature paint property overrides would require either a separate draw call, one per active appearance variant, or re-encoding vertex buffers whenever an appearance activates. UBOs make a third option viable: when an appearance becomes active for a feature, its block in the properties buffer is updated and re-uploaded, or, if the new evaluated block already exists, the indirection table is just repointed at it. Either way, the vertex buffer stays untouched and the shader reads the new values on the next frame.

Both GL JS and GL Native follow the same evaluation logic. When an appearance defines a property, it's added as a per-feature entry in the properties buffer, where the appearance's evaluated value can override the base value on a feature-by-feature basis.

A single label can contain multiple text sections, each with different colors. Features with identical paint properties can share a property block but properties driven by feature-state or similar need their own entry. Just two examples of the complexity we had to solve to get this working.

In the UBO model, a runtime appearance switch leaves the vertex buffer alone: the geometry, the expensive piece, is uploaded once at parse time and reused across every appearance state thereafter.

Figure 5: What Appearances unlock — one map, three kinds of condition

1,200 POIs on a single symbol layer. Four appearance variants, driven by feature-state (category filter), zoom (premium threshold) and measure-light (basemap brightness), compose live.

Results: What it bought

The shared UBO layout is validated by a suite of tests that run on GL JS (WebGL2), GL Native on Metal, Vulkan and OpenGL. The same expected output is checked against four renderers at once. During implementation the suite caught several cases where platforms interpreted the layout differently before they could reach users, and every fix applied across all of them at the same time.

On today's GPU hardware the throughput impact of a UBO indirect lookup versus a direct vertex attribute read is roughly neutral for typical layers, the GPU lookup cost offsets the attribute savings. What changes is the ceiling: a symbol layer can now carry all nine independently data-driven paint properties under a single attribute slot, at any feature count. That's the headroom Appearances needed to exist, and it's the foundation for the work coming next.

Epilogue: The best is yet to come

The UBO approach only covers paint properties right now, and only on symbol layers. Both of those are addressable.

Extending appearances to every layer type is the obvious next paint-side step, and it means migrating those layers onto the UBO model too. As a side effect we expect to collapse a handful of shader variations that exist only to dodge the old attribute-slot ceiling, which is its own win.

Layout properties are the step after that. Appearances can already override them, but every change today still costs a CPU-side geometry recalc. The indirection layer was designed for exactly this case, keeping paint and layout properties deduplication independent, so when the work lands, an appearance switch that touches only layout will be as cheap as a paint switch is today.

The same indirection layer is the right substrate for animations. The plan is to store unique keyframes as property blocks and have the indirection table track each feature's progress through them: the per-frame work then is a tiny update to the table, not a recomputation of the property data.

One more piece of the original design we haven't shipped: making the per-vertex index a representation index rather than a feature index. Today, two features whose evaluated paint property sets coincide share a block only because parse-time deduplication happens to notice. The fuller version makes block sharing a first-class operation: features point at a representation by construction, swapping a feature to a different representation is a single index write.

Beyond appearances, the same mechanism is the right foundation for any feature that needs per-feature GPU state without burning vertex attribute slots.

This work was a prerequisite, not a destination. The rendering architectures on both GL JS and GL Native are now in a position to carry significantly more per-feature expressiveness than before.

Related articles