Skip to content

Add a custom layer with tiles to a globe

Use custom layer to display arbitrary tiles drawn with a custom WebGL shader on a globe.

<!DOCTYPE html>
<html lang="en">
<head>
    <title>Add a custom layer with tiles to a globe</title>
    <meta property="og:description" content="Use custom layer to display arbitrary tiles drawn with a custom WebGL shader on a globe." />
    <meta charset='utf-8'>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel='stylesheet' href='https://unpkg.com/maplibre-gl@5.0.1/dist/maplibre-gl.css' />
    <script src='https://unpkg.com/maplibre-gl@5.0.1/dist/maplibre-gl.js'></script>
    <style>
        body { margin: 0; padding: 0; }
        html, body, #map { height: 100%; }
        #project {
            display: block;
            position: absolute;
            top: 20px;
            left: 50%;
            transform: translate(-50%);
            width: 50%;
            height: 40px;
            padding: 10px;
            border: none;
            border-radius: 3px;
            font-size: 12px;
            text-align: center;
            color: #fff;
            background: #ee8a65;
        }
    </style>
</head>
<body>
<div id="map"></div>
<br />
<button id="project">Toggle projection</button>
<script>
    // This example demonstrates how to draw a custom layer consisting of
    // web mercator tiles with a custom shader.
    // Each drawn tile must be subdivided in order to be properly curved.
    // This example also handles poles.

    // Note that sometimes you can encounter single-pixel seams between tiles
    // of different zoom levels. To fix this, first draw all tiles using meshes
    // *without* borders and mark all drawn pixels in stencil, then draw
    // all tiles again, this time *with* borders and with stencil set to
    // fail all previously drawn pixels.
    // This approach ensures that no pixel that can be drawn inside some tile
    // will be instead covered by a mesh border (which is likely to contain invalid data).
    //
    // MapLibre uses this approach to draw raster tiles on globe. Borders on raster tiles
    // contain ugly stretched textures, we don't want that to cover pixels that could be
    // filled with a valid neighboring tile.

    const EXTENT = 8192;

    // Generate an arbitrary list of tiles to render
    function generateTileList(list, current) {
        list.push(current);
        const subdivide = current.z < 2 || (current.x === current.y && current.z < 3) || (current.x === 0 && current.y === 0 && current.z < 7);
        if (subdivide) {
            for (let x = 0; x < 2; x++) {
                for (let y = 0; y < 2; y++) {
                    generateTileList(list, {
                        x: current.x * 2 + x,
                        y: current.y * 2 + y,
                        z: current.z + 1,
                        wrap: current.wrap,
                    });
                }
            }
        }
    }
    const tilesToRender = [];
    for (let i = -1; i <= 1; i++) {
        generateTileList(tilesToRender, {x: 0, y: 0, z: 0, wrap: i});
    }

    const map = new maplibregl.Map({
        container: 'map',
        style: 'https://demotiles.maplibre.org/style.json',
        zoom: 2,
        center: [7.5, 58]
    });

    map.on('style.load', () => {
        map.setProjection({
            type: 'globe', // Set projection to globe
        });
    });

    document.getElementById('project').addEventListener('click', () => {
        // Toggle projection
        const currentProjection = map.getProjection();
        map.setProjection({
            type: currentProjection.type === 'globe' ? 'mercator' : 'globe',
        });
    });

    const uniforms = [
        'u_matrix',
        'u_projection_fallback_matrix',
        'u_projection_matrix',
        'u_projection_clipping_plane',
        'u_projection_transition',
        'u_projection_tile_mercator_coords',
    ];

    // create a custom style layer to implement the WebGL content
    const highlightLayer = {
        id: 'highlight',
        type: 'custom',
        shaderMap: new Map(),
        meshMap: new Map(),

        // Helper method for creating a shader based on current map projection - globe will automatically switch to mercator when some condition is fulfilled.
        getShader(gl, shaderDescription) {
            if (this.shaderMap.has(shaderDescription.variantName)) {
                return this.shaderMap.get(shaderDescription.variantName);
            }

            // Create GLSL source for vertex shader
            // Note: we pass in-tile position in range 0..EXTENT to projectTile.
            // By default the uniforms for custom layers are set up so that "projectTile" accepts mercator coordinates in range 0..1,
            // but in this example we set the uniforms ourselves to accept range 0..EXTENT just like regular MapLibre rendering.
            const vertexSource = `#version 300 es
            ${shaderDescription.vertexShaderPrelude}
            ${shaderDescription.define}

            in vec2 a_pos;
            out mediump vec2 v_pos;

            void main() {

                gl_Position = projectTile(a_pos);
                // We divide by EXTENT here just so we have something reasonable to display in the pixel shader.
                v_pos = a_pos / float(${EXTENT});
            }`;

            // create GLSL source for fragment shader
            const fragmentSource = `#version 300 es

            precision mediump float;

            in vec2 v_pos;

            out highp vec4 fragColor;
            void main() {
                float alpha = 0.5;
                fragColor = vec4(v_pos, 0.0, 1.0) * alpha;
            }`;

            // create a vertex shader
            const vertexShader = gl.createShader(gl.VERTEX_SHADER);
            gl.shaderSource(vertexShader, vertexSource);
            gl.compileShader(vertexShader);

            // create a fragment shader
            const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
            gl.shaderSource(fragmentShader, fragmentSource);
            gl.compileShader(fragmentShader);

            // link the two shaders into a WebGL program
            const program = gl.createProgram();
            gl.attachShader(program, vertexShader);
            gl.attachShader(program, fragmentShader);
            gl.linkProgram(program);

            this.aPos = gl.getAttribLocation(program, 'a_pos');

            const locations = {};

            for (const uniform of uniforms) {
                locations[uniform] = gl.getUniformLocation(program, uniform);
            }

            const result = {
                program,
                locations
            };

            this.shaderMap.set(shaderDescription.variantName, result);

            return result;
        },

        getTileMesh(gl, x, y, z, border) {
            // What granularity should we use? Query MapLibre's projection object's granularity settings.
            const granularity = map.style.projection.subdivisionGranularity.tile.getGranularityForZoomLevel(z);
            // Do we want north pole geometry?
            const north = y === 0;
            // Do we want south pole geometry?
            const south = y === (1 << z) - 1;
            const key = `${granularity}_${north}_${south}_${border}`;
            if (this.meshMap.has(key)) {
                return this.meshMap.get(key);
            }

            const meshBuffers = maplibregl.createTileMesh({
                granularity,
                generateBorders: border,
                extendToNorthPole: north,
                extendToSouthPole: south,
            }, '16bit');

            const vbo = gl.createBuffer();
            gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
            gl.bufferData(
                gl.ARRAY_BUFFER,
                meshBuffers.vertices,
                gl.STATIC_DRAW
            );
            gl.bindBuffer(gl.ARRAY_BUFFER, null);
            const ibo = gl.createBuffer();
            gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
            gl.bufferData(
                gl.ELEMENT_ARRAY_BUFFER,
                meshBuffers.indices,
                gl.STATIC_DRAW
            );
            gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);

            const mesh = {
                vbo,
                ibo,
                indexCount: meshBuffers.indices.byteLength / 2,
            };
            this.meshMap.set(key, mesh);
            return mesh;
        },

        onAdd (map, gl) {
            // Nothing to do.
        },

        // method fired on each animation frame
        render (gl, args) {
            const {program, locations} = this.getShader(gl, args.shaderData);

            const isBorderDemo = true;

            gl.disable(gl.DEPTH_TEST);
            gl.disable(gl.STENCIL_TEST);
            gl.disable(gl.CULL_FACE);
            gl.enable(gl.BLEND);
            gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);

            gl.useProgram(program);

            const isGlobeProjection = args.shaderData.variantName === 'globe';

            for (const tile of tilesToRender) {
                if (isGlobeProjection && tile.wrap !== 0) {
                    continue; // No need to draw wrapped tiles on a globe.
                }

                const tileID = {
                    wrap: tile.wrap,
                    canonical: {
                        x: tile.x,
                        y: tile.y,
                        z: tile.z,
                    }
                };

                const projectionData = map.transform.getProjectionData({overscaledTileID: tileID, applyGlobeMatrix: true});

                gl.uniform4f(
                    locations['u_projection_clipping_plane'],
                    ...projectionData.clippingPlane // vec4
                );
                gl.uniform1f(
                    locations['u_projection_transition'],
                    projectionData.projectionTransition // float
                );

                // Set tile mercator extents accordingly
                const tileSize = 1.0 / (1 << tile.z);
                gl.uniform4f(
                    locations['u_projection_tile_mercator_coords'],
                    ...projectionData.tileMercatorCoords // vec4
                );

                // Shader variant name effectively tells us what projection code path should be used.
                gl.uniformMatrix4fv(
                    locations['u_projection_matrix'],
                    false,
                    projectionData.mainMatrix
                );
                gl.uniformMatrix4fv(
                    locations['u_projection_fallback_matrix'],
                    false,
                    projectionData.fallbackMatrix
                );
                const mesh = this.getTileMesh(gl, tile.x, tile.y, tile.z, false);
                gl.bindBuffer(gl.ARRAY_BUFFER, mesh.vbo);
                gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, mesh.ibo);
                gl.enableVertexAttribArray(this.aPos);
                gl.vertexAttribPointer(this.aPos, 2, gl.SHORT, false, 0, 0);
                gl.drawElements(gl.TRIANGLES, mesh.indexCount, gl.UNSIGNED_SHORT, 0);
            }
        }
    };

    // add the custom style layer to the map
    map.on('load', () => {
        map.addLayer(highlightLayer, 'countries-label');
    });
</script>
</body>
</html>