Compressing Vertex Attributes to Halve Tile VRAM
A vertex stored as three f32 positions, two f32 texture coordinates and four f32 colour channels is 36 bytes, and almost none of that precision survives contact with the data. Vector tile coordinates are already quantised to a grid; texture coordinates live in a unit square; colour is eight bits per channel by the time it reaches a screen. Storing all of it as single-precision float is a habit rather than a requirement, and replacing it with normalised integers cuts the same vertex to 12 bytes with no visible difference — which is the difference between three zoom levels resident and one. This page is the format choices, the shader-side decode, and the one case where the compression genuinely costs something. It is one stage of VRAM budget management across tile zoom levels.
Runnable reference implementation
The vertex buffer layout declares the formats and WebGPU does the decode in fixed-function hardware, which means the shader sees floats and pays nothing.
const compactLayout: GPUVertexBufferLayout = {
arrayStride: 12, // down from 36
attributes: [
// Position: tile-local, quantised to 16 bits per axis over the tile extent.
{ shaderLocation: 0, offset: 0, format: "unorm16x2" }, // 4 bytes
// Elevation + one packed attribute, also 16-bit normalised.
{ shaderLocation: 1, offset: 4, format: "unorm16x2" }, // 4 bytes
// Colour: 8 bits per channel, decoded to 0..1 by the hardware.
{ shaderLocation: 2, offset: 8, format: "unorm8x4" }, // 4 bytes
],
};
struct VSIn {
// The hardware has already decoded these to floats in 0..1.
@location(0) pos_n : vec2<f32>,
@location(1) elev_n : vec2<f32>,
@location(2) colour : vec4<f32>,
};
struct Tile {
// Scale and offset that map the 0..1 range back to tile-local metres.
pos_scale : vec2<f32>,
elev_range : vec2<f32>,
local_to_clip : mat4x4<f32>,
};
@group(1) @binding(0) var<uniform> tile : Tile;
@vertex
fn vs(in : VSIn) -> @builtin(position) vec4<f32> {
// One multiply-add per axis: the whole decode cost.
let local = in.pos_n * tile.pos_scale;
let z = mix(tile.elev_range.x, tile.elev_range.y, in.elev_n.x);
return tile.local_to_clip * vec4<f32>(local, z, 1.0);
}
The decode is a multiply-add, which is free on any GPU built in the last decade, and it composes naturally with the origin subtraction from relative-to-eye encoding: both are just scale and offset applied on the way in.
Parameter reference
| Value | Setting here | Guidance |
|---|---|---|
| Position format | unorm16x2 |
65 536 steps across a tile: 8 mm at a 512 m tile, finer than any vector tile source carries. |
| Elevation format | unorm16 |
65 536 steps across the tile’s own elevation range, not the world’s — usually centimetres. |
| Colour format | unorm8x4 |
Eight bits per channel is what a display shows; more is storage for nothing. |
| Normals, if present | snorm8x4 |
Octahedral encoding in two snorm8 channels is finer still, at the cost of a decode. |
| Stride | multiple of 4 | Vertex attribute offsets must be 4-byte aligned; pad rather than pack tighter. |
Where the precision actually goes
The argument for compression is not that precision does not matter — it is that the precision being stored was never in the data.
A Mapbox Vector Tile quantises coordinates to a 4096-unit grid by convention, which is 12 bits per axis. Storing those in f32 uses 24 bits of mantissa to represent 12 bits of information, so half of every coordinate byte is provably zero. unorm16 stores 16 bits, which is four bits more than the source carries, and costs half the space.
Elevation is similar. A terrain source quantised to decimetres over a tile spanning 300 metres of relief carries about 12 bits of real information; 16 bits of normalised range over the tile’s own minimum and maximum is again more than enough. The subtlety is that the range must be per tile — normalising against the world’s elevation range would waste most of the codes on values the tile does not contain.
Colour is the clearest case of all: a display shows 8 bits per channel, so anything stored beyond that is discarded at the last step regardless.
The case where compression genuinely costs something is a continuous surface with subtle shading, where 16-bit elevation quantisation can produce visible terracing on a smooth slope under raking light. The fix there is not more bits but a smaller range: per-tile normalisation already helps, and per-tile-per-region helps further.
Compressing indices as well
The vertex buffer is the obvious target and the index buffer is frequently larger, which makes it the second thing to compress and the one most often forgotten.
A triangulated polygon has roughly three indices per triangle and about twice as many triangles as vertices, so a mesh with 10 000 vertices carries around 60 000 indices. At four bytes each that is 240 kilobytes against 120 kilobytes of compressed vertices — the indices are now the larger half.
uint16 indices halve it, at the cost of a 65 535-vertex ceiling per draw. For tiled data that ceiling is rarely a constraint, because a tile with more than 65 535 vertices is already too dense to draw efficiently and wants splitting for other reasons. The practical approach is to use uint16 where the vertex count allows and fall back to uint32 per mesh, recording which in the tile metadata.
There is a further step for meshes that will be resident a long time: index reordering for vertex-cache locality. Reordering triangles so that consecutive ones share vertices raises the post-transform cache hit rate, which reduces vertex shader invocations rather than memory. It is a build-time operation, it does not change the byte count, and on dense terrain meshes it is worth a measurable fraction of the vertex stage.
Failure modes
- Geometry is subtly offset within a tile. The scale and offset uniform does not match the range the encoder used. They have to travel together with the tile.
- Terracing on smooth terrain. Elevation quantised against too wide a range. Normalise per tile, not globally.
- Colours look slightly wrong.
unorm8x4decodes to 0–1 linearly; a source in sRGB needs either an sRGB format or a decode in the shader. - A validation error about vertex attribute offsets. Offsets must be 4-byte aligned. Pad the layout rather than packing to 3-byte boundaries.
- The saving is smaller than expected. The index buffer was not compressed. At three indices per triangle it is often larger than the vertex buffer;
uint16indices halve it where the vertex count allows.
Deciding whether the compression is worth it
Compression is not free of consequences, and three questions settle whether a given pipeline should adopt it.
Does the data justify the precision being stored? For vector tiles and terrain the answer is almost always no — the source is already quantised — and the compression is pure gain. For survey-grade or scientific data the answer may be yes, and the honest response is to compress the attributes that do not carry precision (colour, texture coordinates, normals) and leave the positions alone.
Is VRAM the constraint? If the map comfortably holds every level it wants, halving vertex size buys nothing a user will notice, and the complexity of carrying scale-and-offset metadata with every tile is a cost without a benefit. Compression earns its place when the budget is the thing limiting how much of the map is resident.
Can the metadata travel reliably? The scale and offset have to arrive with the tile and be used exactly. A pipeline where geometry and metadata take different paths — a tile from one endpoint and a style from another — has a real risk of them getting out of step, and a mismatched scale is a subtle, hard-to-attribute offset rather than an obvious failure.
Where all three answers are favourable, which for a tiled basemap they usually are, the compression is one of the highest-return changes available: it costs a multiply-add per vertex and doubles what fits in memory.
Backend / Python interop note
The quantisation belongs on the server, because it is a vectorised column operation there and a per-vertex loop in the browser — and because the scale and offset it produces have to travel with the tile anyway.
import numpy as np
def quantise_unorm16(values: np.ndarray) -> tuple[np.ndarray, float, float]:
"""Return u16 codes plus the offset and scale needed to decode them."""
lo = float(values.min())
hi = float(values.max())
span = max(hi - lo, 1e-9)
codes = np.clip((values - lo) / span, 0.0, 1.0) * 65535.0
return codes.astype(np.uint16), lo, span
The offset and span go in the tile’s metadata, one pair per quantised attribute, and the client feeds them straight into the uniform the shader reads. Deriving them client-side from the received data is the mistake to avoid: a tile whose codes happen not to reach 0 or 65535 would decode against a narrower range than it was encoded with, which shifts every vertex slightly and produces seams between neighbouring tiles.
The other server-side benefit is on the wire. Quantised attributes compress far better than floats, because neighbouring vertices in a spatially sorted tile share high bytes — so the same change that halves VRAM also shrinks the payload, often by more.
Related
- VRAM budget management across tile zoom levels — the topic this page belongs to.
- Estimating VRAM footprint per zoom level — what the saving buys in resident levels.
- Packing vec3 attributes without wasted padding — the alignment half of the same problem.
- Relative-to-eye encoding for f32 coordinate precision — the scale-and-offset this composes with.
- Building an LRU VRAM cache for tile buffers — where the halved tiles are counted.