WebGL интеграция

В стандартной архитектуре Leaflet визуализация карты основана на DOM-тайлах и HTML5 Canvas. Такой подход обеспечивает простоту и предсказуемость рендеринга, но накладывает ограничения при работе с большими массивами данных и сложной графикой. WebGL вводит аппаратное ускорение через GPU, позволяя выполнять отрисовку десятков и сотен тысяч объектов без критической деградации производительности.

WebGL в контексте Leaflet не заменяет основной рендерер карты, а расширяет его, подключаясь как дополнительный слой поверх тайловой системы. Основная задача интеграции — синхронизация координатной системы карты с WebGL-канвасом и управление жизненным циклом рендера в рамках событий карты (zoom, move, rotate-like transformations через pan/zoom).


Canvas и WebGL: различия в контексте картографии

Canvas 2D в Leaflet работает в императивной модели: каждый объект перерисовывается CPU-ом, что приводит к росту затрат при увеличении числа элементов. WebGL использует pipeline GPU:

  • геометрия передаётся в виде буферов (buffers)
  • трансформации выполняются шейдерами
  • отрисовка происходит батчами

Ключевое отличие для картографических приложений — стоимость количества объектов:

  • Canvas: деградация начинается ~10k–50k объектов
  • WebGL: стабильная работа на 100k–1M объектов при корректной оптимизации

Однако WebGL требует управления:

  • шейдерами
  • матрицами преобразований
  • буферами вершин
  • контекстом рендера

Модель интеграции WebGL в Leaflet

WebGL-слой в Leaflet обычно строится как расширение L.Layer, где используется собственный canvas с контекстом webgl:

L.WebGLLayer = L.Layer.extend({
  onAdd: function (map) {
    this._map = map;

    this._canvas = L.DomUtil.create('canvas', 'leaflet-webgl-layer');
    this._canvas.style.position = 'absolute';

    this._gl = this._canvas.getContext('webgl', {
      antialias: false,
      alpha: true
    });

    map.getPanes().overlayPane.appendChild(this._canvas);

    this._initGL();
    this._reset();
    this._update();

    map.on('move zoom resize', this._reset, this);
    map.on('move', this._update, this);
  },

  onRemove: function (map) {
    map.getPanes().overlayPane.removeChild(this._canvas);
    map.off('move zoom resize', this._reset, this);
    map.off('move', this._update, this);
  }
});

Такой слой живёт в рамках overlayPane и синхронизируется с матрицей карты.


Преобразование географических координат в WebGL пространство

Leaflet использует проекцию Web Mercator. В WebGL необходимо переводить LatLng в пиксельные координаты карты:

const point = map.latLngToLayerPoint([lat, lng]);

Далее координаты нормализуются под размер canvas:

const x = point.x;
const y = point.y;

Однако WebGL работает в нормализованных координатах [-1, 1], поэтому требуется матрица преобразования:

function toClipSpace(x, y, width, height) {
  return [
    (x / width) * 2 - 1,
    1 - (y / height) * 2
  ];
}

Матрица трансформации карты

Ключевая проблема интеграции — синхронизация pan/zoom с GPU-матрицей.

Leaflet предоставляет пиксельный offset карты:

const topLeft = map.containerPointToLayerPoint([0, 0]);

WebGL-матрица строится как комбинация:

  • translate (смещение карты)
  • scale (zoom)
  • device pixel ratio correction

Пример матричного подхода:

function createTransform(map) {
  const zoomScale = map.getZoomScale(map.getZoom());
  const offset = map.getPixelOrigin();

  return {
    scale: zoomScale,
    offsetX: -offset.x,
    offsetY: -offset.y
  };
}

Рендеринг большого количества точек

Наиболее типичный кейс WebGL в картографии — отрисовка точек (markers-like data).

Буфер вершин

const positions = new Float32Array(data.length * 2);

for (let i = 0; i < data.length; i++) {
  const p = map.latLngToLayerPoint(data[i]);

  positions[i * 2] = p.x;
  positions[i * 2 + 1] = p.y;
}

Загрузка в GPU:

const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);

Шейдерная модель

Vertex shader отвечает за позиционирование:

attribute vec2 a_position;
uniform vec2 u_resolution;

void main() {
  vec2 zeroToOne = a_position / u_resolution;
  vec2 clipSpace = zeroToOne * 2.0 - 1.0;

  gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);
  gl_PointSize = 4.0;
}

Fragment shader задаёт цвет:

precision mediump float;

uniform vec4 u_color;

void main() {
  gl_FragColor = u_color;
}

Использование requestAnimationFrame и синхронизация с Leaflet

WebGL-слой должен учитывать, что Leaflet управляет перерисовкой через события, а не через игровой loop.

map.on('move', () => {
  requestAnimationFrame(() => {
    layer._render();
  });
});

При этом важно избегать постоянного рендера без изменений состояния карты.


Популярные подходы и библиотеки

Интеграция WebGL в Leaflet редко реализуется вручную полностью. Используются промежуточные библиотеки:

  • regl — функциональный abstraction над WebGL
  • pixi.js — 2D WebGL renderer
  • deck.gl — data-driven GPU layers
  • custom Leaflet plugins (например, WebGL overlay layers)

deck.gl часто используется как внешний WebGL-рендерер, синхронизируемый с Leaflet через viewport:

const viewState = {
  longitude: center.lng,
  latitude: center.lat,
  zoom: map.getZoom(),
  width: map.getSize().x,
  height: map.getSize().y
};

Overlay Pane и z-index модель

Leaflet разделяет карту на panes:

  • tilePane
  • overlayPane
  • markerPane
  • popupPane

WebGL canvas обычно помещается в overlayPane:

map.getPanes().overlayPane.appendChild(canvas);

Важно учитывать порядок отрисовки DOM-слоёв, иначе WebGL может оказаться под тайлами.


Обработка событий карты

WebGL слой обязан реагировать на:

  • zoomend
  • move
  • resize
  • drag

Пример:

map.on('zoomend move resize', () => {
  layer._resize();
  layer._updateTransform();
  layer._render();
});

При zoom изменяется масштаб буфера или пересчитываются координаты.


Оптимизация производительности

Основные техники оптимизации:

Батчинг геометрии

Все точки передаются одним буфером вместо множества draw calls.

Frustum culling на CPU

Удаление объектов вне видимой области:

if (p.x < 0 || p.x > width || p.y < 0 || p.y > height) return;

Использование STATIC_DRAW vs DYNAMIC_DRAW

  • STATIC_DRAW — для фиксированных данных
  • DYNAMIC_DRAW — для часто обновляемых

Минимизация пересчётов координат

Пересчёт LatLng → pixel выполняется только при zoom/move, а не в каждом кадре.


Работа с миллионами точек

При масштабировании данных используются:

  • spatial indexing (quadtree)
  • tiling данных (разбиение по zoom levels)
  • clustering на GPU или CPU

Кластеризация часто предобрабатывается:

function cluster(points, zoom) {
  const size = Math.pow(2, zoom);
  const grid = new Map();

  for (const p of points) {
    const key = `${Math.floor(p.x / size)}:${Math.floor(p.y / size)}`;
    if (!grid.has(key)) grid.set(key, []);
    grid.get(key).push(p);
  }

  return grid;
}

Ограничения WebGL в Leaflet-контексте

Несмотря на производительность, WebGL-слой имеет ограничения:

  • сложность интеграции с DOM-элементами Leaflet (markers, popups)
  • необходимость ручного управления контекстом
  • проблемы с high-DPI scaling
  • ограниченная поддержка текстовой отрисовки без дополнительных библиотек
  • отсутствие встроенной геодезической логики (всё вручную через проекции)

Синхронизация пиксельных систем и GPU координат

Leaflet использует пиксельную систему относительно контейнера карты. WebGL использует clip space.

Связующий слой:

  1. LatLng → world pixel
  2. world pixel → screen pixel
  3. screen pixel → clip space

Этот pipeline должен пересчитываться строго при изменении view state, иначе возникают дрейфы и рассинхронизация слоёв.


Использование instanced rendering

Для большого количества одинаковых объектов применяется instanced rendering:

gl.drawArraysInstanced(gl.POINTS, 0, 1, instanceCount);

Это позволяет отрисовывать тысячи объектов с одним draw call, передавая данные через instance buffers.


Управление глубиной и z-index в WebGL слоях

Leaflet не управляет глубиной WebGL, поэтому z-index реализуется вручную:

  • сортировка данных перед рендером
  • использование alpha blending
  • разделение слоёв на несколько canvas

Обновление данных в реальном времени

Для потоковых данных (например, GPS или IoT):

  • обновляется buffer data через bufferSubData
  • минимизируются realloc операций
  • применяется throttling обновлений
gl.bufferSubData(gl.ARRAY_BUFFER, 0, new Float32Array(updatedPositions));