Add custom layers that follow the terrain
Drape a grid over the terrain with one custom layer, and stand points on the terrain with another.
import * as maplibregl from 'https://unpkg.com/maplibre-gl@6.13.0/dist/maplibre-gl.mjs';
const map = new maplibregl.Map({
container: 'map',
style: 'https://tiles.openfreemap.org/styles/liberty',
center: [7.99, 46.56],
zoom: 11.8,
pitch: 65,
bearing: 170,
maxPitch: 85
});
// The area of the points, [west, north, east, south] in Mercator coordinates
const northWest = maplibregl.MercatorCoordinate.fromLngLat([7.9, 46.65]);
const southEast = maplibregl.MercatorCoordinate.fromLngLat([8.1, 46.5]);
const bounds = [northWest.x, northWest.y, southEast.x, southEast.y];
const meterInMercator = maplibregl.MercatorCoordinate.fromLngLat([8, 46.575]).meterInMercatorCoordinateUnits();
function createProgram(gl, vertexSource, fragmentSource) {
const program = gl.createProgram();
for (const [type, source] of [[gl.VERTEX_SHADER, vertexSource], [gl.FRAGMENT_SHADER, fragmentSource]]) {
const shader = gl.createShader(type);
gl.shaderSource(shader, `#version 300 es\nprecision highp float;\n${source}`);
gl.compileShader(shader);
gl.attachShader(program, shader);
}
gl.linkProgram(program);
return program;
}
// Lines every 0.01 degrees, which MapLibre drapes over the terrain together with its own layers
const gridLayer = {
id: 'grid',
type: 'custom',
onAdd(map, gl) {
this.program = createProgram(gl, `
uniform vec4 u_tile;
out vec2 v_mercator;
void main() {
vec2 corner = vec2(gl_VertexID & 1, gl_VertexID >> 1);
v_mercator = mix(u_tile.xw, u_tile.zy, corner);
gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0);
}`, `
in vec2 v_mercator;
out vec4 fragColor;
void main() {
vec2 lngLat = vec2(v_mercator.x * 360.0 - 180.0, degrees(atan(sinh(3.14159265 * (1.0 - 2.0 * v_mercator.y)))));
vec2 pixels = abs(fract(lngLat / 0.01 - 0.5) - 0.5) / fwidth(lngLat / 0.01);
float line = 1.0 - clamp(min(pixels.x, pixels.y) - 1.0, 0.0, 1.0);
if (line == 0.0) discard;
fragColor = vec4(0.85, 0.1, 0.1, 1.0) * line;
}`);
},
render() {},
// Draws into one terrain tile, whose south-west corner is (-1, -1) in clip space and north-east corner (1, 1)
renderToTerrainTile(gl, {tileID}) {
const tiles = 2 ** tileID.canonical.z;
const x = tileID.canonical.x + tileID.wrap * tiles;
gl.useProgram(this.program);
gl.uniform4f(gl.getUniformLocation(this.program, 'u_tile'), x / tiles, tileID.canonical.y / tiles, (x + 1) / tiles, (tileID.canonical.y + 1) / tiles);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
};
// A point on each grid crossing, which the vertex shader raises onto the terrain with the height map
const pointsLayer = {
id: 'points',
type: 'custom',
renderingMode: '3d',
onAdd(map, gl) {
this.program = createProgram(gl, `
uniform mat4 u_matrix;
uniform vec4 u_bounds;
uniform float u_meterInMercator;
uniform float u_pixelHeight;
uniform sampler2D u_heightMap;
in vec2 a_position;
void main() {
vec2 uv = (a_position - u_bounds.xy) / (u_bounds.zw - u_bounds.xy);
float elevation = texture(u_heightMap, vec2(uv.x, 1.0 - uv.y)).r;
gl_Position = u_matrix * vec4(a_position, elevation * u_meterInMercator, 1.0);
// Lift the point by its radius, so that it stands on the ground instead of half in it
gl_Position.y += 6.0 * u_pixelHeight * gl_Position.w;
gl_PointSize = 12.0;
}`, `
out vec4 fragColor;
void main() {
if (length(gl_PointCoord - 0.5) > 0.5) discard;
fragColor = vec4(0.1, 0.3, 0.9, 1.0);
}`);
const positions = [];
for (let i = 0; i <= 20; i++) {
for (let j = 0; j <= 15; j++) {
const {x, y} = maplibregl.MercatorCoordinate.fromLngLat([7.9 + i * 0.01, 46.5 + j * 0.01]);
positions.push(x, y);
}
}
this.count = positions.length / 2;
this.buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
// The height map holds the elevation in meters in red, and needs a float texture
gl.getExtension('EXT_color_buffer_float');
this.heightMap = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.heightMap);
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA32F, 512, 512);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
},
prerender(gl, options) {
options.renderTerrainHeightMap?.({texture: this.heightMap, width: 512, height: 512, bounds});
},
render(gl, options) {
gl.useProgram(this.program);
gl.uniformMatrix4fv(gl.getUniformLocation(this.program, 'u_matrix'), false, options.defaultProjectionData.mainMatrix);
gl.uniform4fv(gl.getUniformLocation(this.program, 'u_bounds'), bounds);
gl.uniform1f(gl.getUniformLocation(this.program, 'u_meterInMercator'), meterInMercator);
gl.uniform1f(gl.getUniformLocation(this.program, 'u_pixelHeight'), 2 / gl.drawingBufferHeight);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.heightMap);
gl.uniform1i(gl.getUniformLocation(this.program, 'u_heightMap'), 0);
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
gl.enableVertexAttribArray(gl.getAttribLocation(this.program, 'a_position'));
gl.vertexAttribPointer(gl.getAttribLocation(this.program, 'a_position'), 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.POINTS, 0, this.count);
}
};
await map.once('load');
map.addSource('terrain', {type: 'raster-dem', url: 'https://tiles.mapterhorn.com/tilejson.json'});
map.addSource('hillshade', {type: 'raster-dem', url: 'https://tiles.mapterhorn.com/tilejson.json'});
map.setTerrain({source: 'terrain'});
const firstSymbolLayer = map.getStyle().layers.find(layer => layer.type === 'symbol');
map.addLayer({id: 'hillshade', type: 'hillshade', source: 'hillshade', paint: {'hillshade-exaggeration': 0.3}}, firstSymbolLayer.id);
map.addLayer(gridLayer, firstSymbolLayer.id);
map.addLayer(pointsLayer);
<!DOCTYPE html>
<html lang="en">
<head>
<title>Add custom layers that follow the terrain</title>
<meta property="og:description" content="Drape a grid over the terrain with one custom layer, and stand points on the terrain with another." />
<meta property="og:category" content="Terrain & Hillshade" />
<meta property="og:created" content="2026-09-30" />
<meta charset='utf-8'>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel='stylesheet' href='https://unpkg.com/maplibre-gl@6.13.0/dist/maplibre-gl.css' />
<style>
body { margin: 0; padding: 0; }
html, body, #map { height: 100%; }
</style>
</head>
<body>
<div id="map"></div>
<script type="module">
import * as maplibregl from 'https://unpkg.com/maplibre-gl@6.13.0/dist/maplibre-gl.mjs';
const map = new maplibregl.Map({
container: 'map',
style: 'https://tiles.openfreemap.org/styles/liberty',
center: [7.99, 46.56],
zoom: 11.8,
pitch: 65,
bearing: 170,
maxPitch: 85
});
// The area of the points, [west, north, east, south] in Mercator coordinates
const northWest = maplibregl.MercatorCoordinate.fromLngLat([7.9, 46.65]);
const southEast = maplibregl.MercatorCoordinate.fromLngLat([8.1, 46.5]);
const bounds = [northWest.x, northWest.y, southEast.x, southEast.y];
const meterInMercator = maplibregl.MercatorCoordinate.fromLngLat([8, 46.575]).meterInMercatorCoordinateUnits();
function createProgram(gl, vertexSource, fragmentSource) {
const program = gl.createProgram();
for (const [type, source] of [[gl.VERTEX_SHADER, vertexSource], [gl.FRAGMENT_SHADER, fragmentSource]]) {
const shader = gl.createShader(type);
gl.shaderSource(shader, `#version 300 es\nprecision highp float;\n${source}`);
gl.compileShader(shader);
gl.attachShader(program, shader);
}
gl.linkProgram(program);
return program;
}
// Lines every 0.01 degrees, which MapLibre drapes over the terrain together with its own layers
const gridLayer = {
id: 'grid',
type: 'custom',
onAdd(map, gl) {
this.program = createProgram(gl, `
uniform vec4 u_tile;
out vec2 v_mercator;
void main() {
vec2 corner = vec2(gl_VertexID & 1, gl_VertexID >> 1);
v_mercator = mix(u_tile.xw, u_tile.zy, corner);
gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0);
}`, `
in vec2 v_mercator;
out vec4 fragColor;
void main() {
vec2 lngLat = vec2(v_mercator.x * 360.0 - 180.0, degrees(atan(sinh(3.14159265 * (1.0 - 2.0 * v_mercator.y)))));
vec2 pixels = abs(fract(lngLat / 0.01 - 0.5) - 0.5) / fwidth(lngLat / 0.01);
float line = 1.0 - clamp(min(pixels.x, pixels.y) - 1.0, 0.0, 1.0);
if (line == 0.0) discard;
fragColor = vec4(0.85, 0.1, 0.1, 1.0) * line;
}`);
},
render() {},
// Draws into one terrain tile, whose south-west corner is (-1, -1) in clip space and north-east corner (1, 1)
renderToTerrainTile(gl, {tileID}) {
const tiles = 2 ** tileID.canonical.z;
const x = tileID.canonical.x + tileID.wrap * tiles;
gl.useProgram(this.program);
gl.uniform4f(gl.getUniformLocation(this.program, 'u_tile'), x / tiles, tileID.canonical.y / tiles, (x + 1) / tiles, (tileID.canonical.y + 1) / tiles);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
};
// A point on each grid crossing, which the vertex shader raises onto the terrain with the height map
const pointsLayer = {
id: 'points',
type: 'custom',
renderingMode: '3d',
onAdd(map, gl) {
this.program = createProgram(gl, `
uniform mat4 u_matrix;
uniform vec4 u_bounds;
uniform float u_meterInMercator;
uniform float u_pixelHeight;
uniform sampler2D u_heightMap;
in vec2 a_position;
void main() {
vec2 uv = (a_position - u_bounds.xy) / (u_bounds.zw - u_bounds.xy);
float elevation = texture(u_heightMap, vec2(uv.x, 1.0 - uv.y)).r;
gl_Position = u_matrix * vec4(a_position, elevation * u_meterInMercator, 1.0);
// Lift the point by its radius, so that it stands on the ground instead of half in it
gl_Position.y += 6.0 * u_pixelHeight * gl_Position.w;
gl_PointSize = 12.0;
}`, `
out vec4 fragColor;
void main() {
if (length(gl_PointCoord - 0.5) > 0.5) discard;
fragColor = vec4(0.1, 0.3, 0.9, 1.0);
}`);
const positions = [];
for (let i = 0; i <= 20; i++) {
for (let j = 0; j <= 15; j++) {
const {x, y} = maplibregl.MercatorCoordinate.fromLngLat([7.9 + i * 0.01, 46.5 + j * 0.01]);
positions.push(x, y);
}
}
this.count = positions.length / 2;
this.buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
// The height map holds the elevation in meters in red, and needs a float texture
gl.getExtension('EXT_color_buffer_float');
this.heightMap = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.heightMap);
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA32F, 512, 512);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
},
prerender(gl, options) {
options.renderTerrainHeightMap?.({texture: this.heightMap, width: 512, height: 512, bounds});
},
render(gl, options) {
gl.useProgram(this.program);
gl.uniformMatrix4fv(gl.getUniformLocation(this.program, 'u_matrix'), false, options.defaultProjectionData.mainMatrix);
gl.uniform4fv(gl.getUniformLocation(this.program, 'u_bounds'), bounds);
gl.uniform1f(gl.getUniformLocation(this.program, 'u_meterInMercator'), meterInMercator);
gl.uniform1f(gl.getUniformLocation(this.program, 'u_pixelHeight'), 2 / gl.drawingBufferHeight);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.heightMap);
gl.uniform1i(gl.getUniformLocation(this.program, 'u_heightMap'), 0);
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
gl.enableVertexAttribArray(gl.getAttribLocation(this.program, 'a_position'));
gl.vertexAttribPointer(gl.getAttribLocation(this.program, 'a_position'), 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.POINTS, 0, this.count);
}
};
await map.once('load');
map.addSource('terrain', {type: 'raster-dem', url: 'https://tiles.mapterhorn.com/tilejson.json'});
map.addSource('hillshade', {type: 'raster-dem', url: 'https://tiles.mapterhorn.com/tilejson.json'});
map.setTerrain({source: 'terrain'});
const firstSymbolLayer = map.getStyle().layers.find(layer => layer.type === 'symbol');
map.addLayer({id: 'hillshade', type: 'hillshade', source: 'hillshade', paint: {'hillshade-exaggeration': 0.3}}, firstSymbolLayer.id);
map.addLayer(gridLayer, firstSymbolLayer.id);
map.addLayer(pointsLayer);
</script>
</body>
</html>