VRT Mosaic Protocol
vrt:// — reading a GDAL VRT mosaic in the browser, reprojecting it with proj4, and the two ways it refuses a tile rather than stalling
A .vrt is not a raster. It is an XML index over many real ones: a virtual raster of rasterXSize × rasterYSize pixels with a GeoTransform, and a list of sources each saying this file's SrcRect lands at this DstRect of the virtual raster.
<VRTDataset rasterXSize="985000" rasterYSize="975000">
<GeoTransform>97999.5, 1, 0, 7111000.5, 0, -1</GeoTransform>
<VRTRasterBand dataType="Float32" band="1">
<NoDataValue>-99999</NoDataValue>
<ComplexSource>
<SourceFilename relativeToVRT="1">RGEALTI_..._D001_2020-11-13.tif</SourceFilename>
<SrcRect xOff="0" yOff="0" xSize="111000" ySize="101000" />
<DstRect xOff="735000" yOff="506000" xSize="111000" ySize="101000" />
<NODATA>-99999</NODATA>
</ComplexSource>
…92 more
</VRTRasterBand>
</VRTDataset>That is IGN's RGE ALTI at 1 m for the whole of mainland France: 93 departmental COGs behind a 40 KB index, a virtual raster just under a million pixels on a side.
Why it used to be titiler-only
Nothing in a VRT needs GDAL to read. The files it points at are ordinary COGs, and geotiff.js already Range-reads those — it is the same reader cog:// uses. What GDAL does that had to be redone is the bookkeeping: which sources a requested window touches, and where inside each one to read.
titiler does it by handing the whole problem over: vrt:///vsicurl/<url> through rasterio, and GDAL's VRT driver resolves sources and warps in C. lib/vrt-protocol.ts does the same three steps in the browser — intersect, read, composite — so a VRT now works in either streaming mode instead of only the server one.
Reprojection
A VRT is in whatever CRS its sources are, and MapLibre asks for Web Mercator. The library's three shipped mosaics are three different answers: AW3D30 is plain EPSG:4326, the OpenTopography LiDAR surveys are UTM zones, and the RGE ALTI repack is Lambert-93.
The <SRS> element carries the projection as WKT and proj4 — already a dependency — turns that into a transform. It is not called per output pixel, though: 65 536 proj4 calls measured 21 ms of main-thread time per tile, and sixteen of those in a viewport is a third of a second of jank.
GDAL's warper does not do it that way either. It builds an approximate transformer: evaluate the real projection on a coarse grid, interpolate between the nodes, and subdivide while the error is above a threshold — -et, 0.125 px by default. The same here, starting at 16 cells and doubling. The error is quadratic in cell size, so it is entirely a function of zoom; over Lambert-93 a 16-cell grid is 930 px out at z3, 0.2 px at z9 and 5×10⁻⁵ px at z15. The grid costs well under 1 ms.
Two wrinkles the real files forced:
a_srs= in the URL. The RGE ALTI repack has no <SRS> element at all; the library entry's URL ends ...FXX.vrt?a_srs=EPSG:2154, which is GDAL's assign-SRS open option. The parser honours it as an override, so without that query parameter that mosaic would land in the Gulf of Guinea.
Bare EPSG codes. proj4 ships definitions for WGS84, 4326, 3857 and little else, so EPSG:2154 is not a definition it can parse. Codes are resolved through https://epsg.io/<code>.proj4 and cached — the same lookup TerraDrawSystem.tsx already does for a GeoPackage's SRS.
Holes, and why nearest beats bilinear
The read started on resampleMethod: "bilinear", which is wrong here in a way that is invisible until you look at the numbers. These mosaics carry their nodata in the same band as their elevations: -99999 for RGE ALTI. Interpolating across that edge blends -99999 with real ground, and a z7 tile over France came back with a minimum of -11 650 m — inside every sane guard, and a kilometres-deep gash along every source boundary. Nearest, and the same tile reads 22 m.
Holes are then written as 0 m with alpha 254, the convention demdiff:// documents: a transparent pixel premultiplies to RGB 0, which in Terrain-RGB is the floor at -10 000 m.
The output is Terrain-RGB rather than Terrarium, deliberately — that is what titiler's algorithm=terrainrgb returns for the same file, so both modes agree with the encoding: "terrainrgb" that controls-utils.tsx already declares for a VRT source, and switching streaming mode changes nothing downstream.
The zoom range comes from the index
A VRT has no COG header, so useCogMetadata has nothing to detect from and a client-read VRT would otherwise mount at a flat 0-20. getVrtInfo() reads a range out of the index instead, and useVrtInfo hands it to both the primary Source and useClientDemUpstream:
| how | |
|---|---|
| bounds | the boundary sampled at 64 points through the inverse transform, because a projected CRS bows at the edges |
| maxzoom | the zoom whose 256 px tile is about as fine as one source pixel |
| minzoom | where a tile spans about four source footprints |
Measured resolution is in mercator metres, not ground metres, because that is the unit MapLibre's zoom is defined in. It is taken over a short step at the raster's centre — an earlier version measured across the full width and silently collapsed on a global mosaic, because proj4 normalises longitude 180 to -180, so AW3D30's two edges projected to the same mercator x and its 30 m grid read as zero metres per pixel. The same normalisation still understates the east edge of a whole-world mosaic, so a raster spanning the full mercator width is taken at its word as -180 to 180.
The minzoom is fed to the Source, not to the camera: TerrainSources reports only the detected COG minzoom to onZoomRangeChange, because that one becomes map.setMinZoom() and would fence the viewport. A VRT's floor exists to stop us requesting tiles, not to stop the user zooming out.
Two refusals
Both exist because the alternative is a map that hangs.
Too many source files. A tile touching more than 40 files is refused. At z3 over a national mosaic that is hundreds of Range-read openings for one tile; the derived minzoom normally keeps MapLibre well above it.
Too many source pixels. A file with no overviews has to decode its full-resolution window however small the output is. Haiti's 1.5 m mosaic — 369 UInt16 tiles, no overviews — took 15 s for a single z11 tile that way, decoding 12 500 × 12 400 source pixels for a 256 × 256 output. The read is refused above 8 Mpx, estimated from the file's overview count.
Both messages end the same way, and it is not a platitude: zoom in, or serve this source through titiler. titiler has GDAL's own overview handling, and the per-source Always serve via titiler switch in the source modal is what that sentence points at. It is the same cogViaTitiler flag a non-Mercator COG uses, now offered for VRT too.
A mosaic's source files need CORS, not just the .vrt. OpenTopography's S3 echoes the origin and exposes Content-Range; data.cquest.org (the RGE ALTI repack) answers Access-Control-Allow-Origin: * but sends no Access-Control-Expose-Headers, so a browser hides Content-Range from geotiff.js. If a mosaic reads in curl and not in the app, that pairing is the first thing to check — and the titiler pin is the fix.
What it deliberately does not do
ComplexSourcescale/offset and<LUT>are ignored.<NODATA>is honoured, which is the part that matters for elevation.- Only the first band. An elevation VRT is single-band, and a multi-band one would need a channel policy the URL has no way to express.
- No recursion into sources that are themselves VRTs.
- Rotated
GeoTransforms (non-zero rotation terms) are rejected outright.
Measured
One tile, cold, over a real network:
| mosaic | CRS | sources | tile | touched | time |
|---|---|---|---|---|---|
| AW3D30 global 30 m | 4326 | 23 997 | z11 Zermatt | 2 | 1.3 s |
| AW3D30 global 30 m | 4326 | 23 997 | z8 Alps | 4 | 1.5 s |
| RGE ALTI 1 m | Lambert-93 | 93 | z15 Montparnasse | 4 | 0.43 s |
| RGE ALTI 1 m | Lambert-93 | 93 | z7 France | 22 | 1.2 s |
| Haiti DTM 1.5 m | UTM 18N | 369 | z15 Port-au-Prince | 1 | 1.7 s |
The index fetch and parse is once per URL per session and cached, as are the opened GeoTIFFs: 2.1 s for AW3D30's 10 MB of XML over 23 997 sources, 40 ms for RGE ALTI's 40 KB.