Skip to content

Animate an icon on the GPU

Draw an animated icon straight into the icon atlas with WebGL, without moving its pixels through the CPU.

import * as maplibregl from 'https://unpkg.com/maplibre-gl@6.3.0/dist/maplibre-gl.mjs';

const map = new maplibregl.Map({container: 'map', style: 'https://demotiles.maplibre.org/style.json'});

// corner hues that cycle once a second, as cosines of time so that they never jump
const vertexSource = `#version 300 es
const vec2 corners[3] = vec2[3](vec2(0.0, 0.9), vec2(-0.8, -0.5), vec2(0.8, -0.5));
uniform float u_time;
out vec3 v_color;
void main() {
    float hue = u_time + float(gl_VertexID) / 3.0;
    v_color = 0.4 + cos(6.2832 * (hue + vec3(0.0, 1.0 / 3.0, 2.0 / 3.0)));
    // the atlas holds its rows top to bottom and a framebuffer writes them bottom to top
    gl_Position = vec4(corners[gl_VertexID].x, -corners[gl_VertexID].y, 0.0, 1.0);
}`;

const fragmentSource = `#version 300 es
precision highp float;
in vec3 v_color;
out vec4 fragColor;
void main() {
    fragColor = vec4(v_color, 1.0);
}`;

// a StyleImageInterface whose `data` renders the icon with WebGL instead of listing its pixels
// Search for StyleImageWebGLData in https://maplibre.org/maplibre-gl-js/docs/API/
const rainbowTriangle = {
    width: 128, height: 128,

    // called once a frame: keep the frames coming, and report that the icon has changed
    render () {
        map.triggerRepaint();
        return true;
    },

    data: {
        // `this` is the data object, so the icon's own state is reached by name
        renderWithWebGL ({gl, texture, x, y, width, height}) {
            if (!rainbowTriangle.program) rainbowTriangle.setup(gl);
            gl.bindFramebuffer(gl.FRAMEBUFFER, rainbowTriangle.framebuffer);
            gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);

            // the viewport confines this draw to the icon's own slot, which it repaints in full
            gl.viewport(x, y, width, height);
            gl.useProgram(rainbowTriangle.program);
            gl.uniform1f(rainbowTriangle.uTime, performance.now() / 1000);
            gl.drawArrays(gl.TRIANGLES, 0, 3);
        }
    },

    setup (gl) {
        this.program = gl.createProgram();
        for (const [type, source] of [[gl.VERTEX_SHADER, vertexSource], [gl.FRAGMENT_SHADER, fragmentSource]]) {
            const shader = gl.createShader(type);
            gl.shaderSource(shader, source);
            gl.compileShader(shader);
            gl.attachShader(this.program, shader);
        }
        gl.linkProgram(this.program);
        this.uTime = gl.getUniformLocation(this.program, 'u_time');
        this.framebuffer = gl.createFramebuffer();
        this.gl = gl;
    },

    // also called when the WebGL context is lost, so setup has to be repeatable
    onRemove () {
        if (!this.program) return;
        this.gl.deleteProgram(this.program);
        this.gl.deleteFramebuffer(this.framebuffer);
        this.program = undefined;
    }
};

map.on('load', () => {
    map.addImage('rainbow-triangle', rainbowTriangle, {pixelRatio: 2});
    map.addSource('points', {type: 'geojson', data: {type: 'Point', coordinates: [0, 0]}});
    map.addLayer({id: 'points', type: 'symbol', source: 'points', layout: {'icon-image': 'rainbow-triangle'}});
});
<!DOCTYPE html>
<html lang="en">
<head>
    <title>Animate an icon on the GPU</title>
    <meta property="og:description" content="Draw an animated icon straight into the icon atlas with WebGL, without moving its pixels through the CPU." />
    <meta property="og:category" content="Icons & Symbols" />
    <meta property="og:created" content="2026-08-03" />
    <meta charset='utf-8'>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel='stylesheet' href='https://unpkg.com/maplibre-gl@6.3.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.3.0/dist/maplibre-gl.mjs';

    const map = new maplibregl.Map({container: 'map', style: 'https://demotiles.maplibre.org/style.json'});

    // corner hues that cycle once a second, as cosines of time so that they never jump
    const vertexSource = `#version 300 es
    const vec2 corners[3] = vec2[3](vec2(0.0, 0.9), vec2(-0.8, -0.5), vec2(0.8, -0.5));
    uniform float u_time;
    out vec3 v_color;
    void main() {
        float hue = u_time + float(gl_VertexID) / 3.0;
        v_color = 0.4 + cos(6.2832 * (hue + vec3(0.0, 1.0 / 3.0, 2.0 / 3.0)));
        // the atlas holds its rows top to bottom and a framebuffer writes them bottom to top
        gl_Position = vec4(corners[gl_VertexID].x, -corners[gl_VertexID].y, 0.0, 1.0);
    }`;

    const fragmentSource = `#version 300 es
    precision highp float;
    in vec3 v_color;
    out vec4 fragColor;
    void main() {
        fragColor = vec4(v_color, 1.0);
    }`;

    // a StyleImageInterface whose `data` renders the icon with WebGL instead of listing its pixels
    // Search for StyleImageWebGLData in https://maplibre.org/maplibre-gl-js/docs/API/
    const rainbowTriangle = {
        width: 128, height: 128,

        // called once a frame: keep the frames coming, and report that the icon has changed
        render () {
            map.triggerRepaint();
            return true;
        },

        data: {
            // `this` is the data object, so the icon's own state is reached by name
            renderWithWebGL ({gl, texture, x, y, width, height}) {
                if (!rainbowTriangle.program) rainbowTriangle.setup(gl);
                gl.bindFramebuffer(gl.FRAMEBUFFER, rainbowTriangle.framebuffer);
                gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);

                // the viewport confines this draw to the icon's own slot, which it repaints in full
                gl.viewport(x, y, width, height);
                gl.useProgram(rainbowTriangle.program);
                gl.uniform1f(rainbowTriangle.uTime, performance.now() / 1000);
                gl.drawArrays(gl.TRIANGLES, 0, 3);
            }
        },

        setup (gl) {
            this.program = gl.createProgram();
            for (const [type, source] of [[gl.VERTEX_SHADER, vertexSource], [gl.FRAGMENT_SHADER, fragmentSource]]) {
                const shader = gl.createShader(type);
                gl.shaderSource(shader, source);
                gl.compileShader(shader);
                gl.attachShader(this.program, shader);
            }
            gl.linkProgram(this.program);
            this.uTime = gl.getUniformLocation(this.program, 'u_time');
            this.framebuffer = gl.createFramebuffer();
            this.gl = gl;
        },

        // also called when the WebGL context is lost, so setup has to be repeatable
        onRemove () {
            if (!this.program) return;
            this.gl.deleteProgram(this.program);
            this.gl.deleteFramebuffer(this.framebuffer);
            this.program = undefined;
        }
    };

    map.on('load', () => {
        map.addImage('rainbow-triangle', rainbowTriangle, {pixelRatio: 2});
        map.addSource('points', {type: 'geojson', data: {type: 'Point', coordinates: [0, 0]}});
        map.addLayer({id: 'points', type: 'symbol', source: 'points', layout: {'icon-image': 'rainbow-triangle'}});
    });
</script>
</body>
</html>