Возникла необходимость зафиксировать опыт с последнего проекта по прокачке производительности картографического сервиса. Так сказать, чтобы 2 раза не вставать при передаче опыта. И начнём с постановки, чтобы сразу определиться с аудиторией, кому мимо, а кому больше узнать как "прожевывать" и отображать на UI от 100К объектов в секунду и не лагать. Ну а кто-то вообще не в танке про картографические сервисы и хочет "на борт". Но для второй категории оговорюсь, что в статье минимальная теоретическая часть для затравки, но достаточно ссылок чтобы прокачаться до нужного уровня.
Что вас ждёт по катом.
1. MapTiler/Maplibre - картографический провайдер и UI фрэймворк для работы с ним.
2. Создание своих слоёв данных на карте.
3. Рендеринг большого объёма данных на WebGL/WebGPU. Начнём от 100К.
4. Оптимизация рендеринга с ручной подготовкой буферов для GPU.
5. Обновление данных слоя в realtime. Начнём молотить от 1M объектов.
6. Сериализация данных в ArrayBuffer для передачи напрямую в GPU.
Итак, имеется карта. В статье будет использоваться картографическая библиотека на основе форка Mapbox, - Maplibre. Имеется сервис, который генерирует большое количество объектов для их отображения на карте. Положение объектов часто меняется. В качестве примера для генераторов данных можно представить такси, самокаты, просто автомобили с трекерами. Задача, - отображать реальное или близкое к реальному положение объектов на карте. И поскольку данные у нас не статичные и их объём может быть довольно большим даже в области видимости, всё это нужно ещё умножить на FPS, с которым мы хотим видеть текущее состояние объектов мониторинга.
Ну очень коротко про устройство карт.
Многие картографические библиотеки имеют слоистую структуру. Такой бутерброд, на каждом слое которого данные определённого типа. Например: Ландшафт, горы, водоёмы. На другом слое дороги, тропинки. В следующем, городская инфраструктура, и так далее. Чтобы показать картинку ниже, используется 100+ разных слоёв.

Таким образом, имея какой-то датасет, который необходимо отобразить на карте, мы просто подбираем для него необходимую реализацию Layer`а. Или, в крайнем случае, пишем свою.
Плавать научились, нальём таки в бассейн воды.
Начнём с заготовки проекта и установки основных библиотек.
Стартовый код.
npx create-react-app maplibre-demo --template typescript
По доброй традиции, удаляем весь код в /src, который нагенерил CRA, оставляя только стартовый компонент App.tsx и index.tsx в девственно чистом виде...
index.tsx
import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; const root = ReactDOM.createRoot( document.getElementById("root") as HTMLElement ); root.render( <React.StrictMode> <App /> </React.StrictMode> );
App.tsx
import React from "react"; function App() { return <div></div>; } export default App;
Проверяем через npm start, что рендерится чистая страница, а в консоли нет ничего лишнего, что помешало бы потом с отладкой.
Далее сама JS библиотека maplibre и React-обёртка. У malibre очень хорошая документация и примеры кода на чистейшем Javascript. Мы же сразу воспользуемся React-обёрткой.
npm install react-map-gl maplibre-gl
Картой уже можно пользоваться, но нет данных. Их можно получить у провайдера Maptiler. Регистрируемся здесь и получаем api key (ну совершенно бесплатно, пока).
Добавим отдельный компонент для инициализации canvas карты. Не забудьте определить константу YOU_API_KEY
MaplibreMap.tsx
import Map from "react-map-gl/maplibre"; const MaplibreMap = () => { return ( <Map initialViewState={{ longitude: 37.6209903877284, latitude: 55.747710687697804, zoom: 15, pitch: 60, }} style={{ height: "calc(100vh - 70px)" }} mapStyle={`https://api.maptiler.com/maps/streets-v2/style.json?key=${YOU_API_KEY}`} ></Map> ); }; export default MaplibreMap;
App.tsx
function App() { return <MaplibreMap />; }
И если всё сделано правильно и я ничего не забыл, то мы увидим картинку, похожую на ту, что выше.
Слои для рендеринга данных.
Каждый слой, как писалось выше, отвечает за свой тип данных. Данные могут быть в различном виде: иконки, растры, геометрические фигуры и даже 3D объекты. И всё это можно рендерить на карте. Таким образом, именно слой реализует базовый функционал визуального представления данных, он же отвечает и за их загрузку и предварительную подготовку (парсинг). Мы будем использовать библиотеку deck.gl в которой уже решены все задачи с которыми столкнётся разработчик картографического сервиса. Для интереса, можете взглянуть какого типа данные умеет рендерить сегодня deck.gl. Это 30+ различных форматов. Хочу заметить, что в deck решены вопросы парсинга данных в WebWorker, разбиения данных на чанки, рендеринг через WebGL/WebGPU, оптимизация форматов данных и много чего ещё для оптимизации больших датасетов. Официальная документация манипулирует цифрами от 1М объектов.
npm install deck.gl
И протестируем слой для рендеринга "простых" точек. Для этого напишем сервис для генерации случайных данных в некоторых пределах.
Весь код я более не буду приводить, его можно посмотреть непосредственно в репозитории для статьи, буду выделять только ключевые моменты. Слой PointLayer я сделал наследником ScatterplotLayer, но пока он пустой. Сделано для удобства, чтобы можно было легко добавить дополнительную логику.
data-service.ts (генерирует объекты в заданной области карты)
for (let i = 0; i < count; i++) { const lng = random() * width * 3 + (boundingBox.nw[0] - width); const lat = random() * height * 3 + (boundingBox.sw[1] - height); const alt = random() * 1000; const point: Point = { coordinates: [lng, lat, alt], color: [ round(random() * 255), round(random() * 255), round(random() * 255), ], }; result.push(point); }
MaplibreMap.tsx (подключаем слой)
const POINT_COUNT = 100_000; const data = generateData(POINT_COUNT); const MaplibreMap = () => { const [loaded, setLoaded] = useState(false); const [layers, setLayers] = useState<Layer[]>([]); useEffect(() => { if (loaded) { setLayers([createPointLayer(data)]); } }, [loaded]); <Map // ... onLoad={() => setLoaded(true)} > <DeckGLOverlay layers={layers} /> </Map> }
point-layer-factory.ts (создаём слой)
export function createPointLayer(data: Point[]): PointLayer { return new PointLayer({ id: "point-layer", data, getPosition: (d) => d.coordinates, getColor: (d) => d.color, getRadius: 5, }); }
Уже можно насладится как мы рендерим разное количество объектов на экране с помощью WebGPU или WebGL, если первый не поддерживается. Я сразу начал со 100К.

Двигаем.
Наступает самое интересное. Как обновлять данные? В нашем случае заставить объекты перемещаться по экрану.
В начале немного теории для понимания того, как происходит обновление данных слоя. DeckGL устроен таким образом, что для изменения свойств слоя необходимо создать новый экземпляр слоя с новыми свойствами на с таким же свойством ID. В документации такая техника позиционируется как подобная React, где происходит рендеринг только необходимых компонентов на основе внутреннего состояния. DeckGL тоже имеет внутреннее состояние слоя и кэш этих состояний в котором хранит ключевые свойства слоя влияющие на рендеринг. Сравнение происходит по свойству ID. Если при создании слоя ID в кэше нет, то вызывается функция initializeState, которая должна указать какие свойства переданные в конструктор необходимо кэшировать. Если слой найден в кэше, то происходит сравнение переданных свойств и свойств хранящихся в кэше и делаются выводы, что и как нужно рендерить.
Для начала напишем сервис для обновления координат объектов. В статье приводить его реализацию нет смысла. Так же как и примитивный сервис статистики, который снимает время работы двух ключевых методов слоя: draw и update.
Съём статистики в PointLayer
export class PointLayer extends ScatterplotLayer { public override draw(options: any): void { const startTime = performance.now(); super.draw(options); addDrawTime(performance.now() - startTime); } public override _update(): void { const startTime = performance.now(); super._update(); addUpdateTime(performance.now() - startTime); } }
MaplibreMap.tsx
useEffect(() => { if (loaded) { setLayers([createPointLayer(data)]); setInterval(() => { setLayers([createPointLayer((data = updatePoints(data)))]); }, 1000 / FPS); } }, [loaded]);
функция updatePoints двигает объекты в зависимости от их скорости движения и направления. И уже можно посмотреть статистику, хотя и собранную довольно примитивно.

FPS: 10 COUNT_OBJECTS: 100_000 ---- draw time: 0.1ms update time: 15ms
И первые выводы, которые можно сделать. Основное время тратится на операцию update. Пришло время для следующей порции теории. Свойство data у слоя принимает массив данных определённой модели. DeckGL итерируется по этому массиву и на каждый элемент вызывает функцию, которая должна вернуть значение для рендеринга. Например getPosition и getColor. И собирает из этих значений буфер (массив чиселок) для отправки этого массива в GPU.
// Модель данных export interface Point { coordinates: [number, number, number]; color: [number, number, number]; azimuth: number; speed: number; } // Фабрика для создания слоя export function createPointLayer(data: Point[]): PointLayer { return new PointLayer({ id: "point-layer", data, getPosition: (d) => d.coordinates, getColor: (d) => d.color, getRadius: 5, }); }
Обновление 100К объектов за 15ms, результат конечно неплохой, но надо понимать, что даже при 10FPS на обновление мы уже тратим 150ms центрального процессора. А если речь идёт о 1М объектов? Тогда ныряем глубже.
Пишем GPU буфер сами.
В deckgl есть способ указания свойства data в том виде, в котором он уже пригоден для отправки в GPU. Т.е. фактически можно пропустить проход по массиву в функции update, и сложность update из O(n) становится O(1).
Вот так выглядит подобный буфер
const GPU_ITEM_SIZE = 16; // Размер модели данных в байтах return { length: data.length / GPU_ITEM_SIZE, attributes: { getPosition: { value: buffer, type: 'float32', size: 3, offset: 0, stride: GPU_ITEM_SIZE }, getFillColor: { value: buffer, type: 'uint8', size: 4, offset: 12, stride: GPU_ITEM_SIZE }, }, };
Требования таких конструкций различаются от слоя к слою, как и модели данных для различных слоёв. Коротко распишу, что тут зашифровано. Подробнее, само собой, в официальной документации:
value: Float32Array с чередующимися друг за другом атрибутами объекта type: тип данных, как GPU должен воспринимать поток байтов size: количество отднотипных значений, т.е. размер массива stride: количество байтов на все значения одного элемента модели offset: смещение в байтах с которого нужно читать значение атрибута
Таким образом, имея такое описание, render запустит цикл по буферу и на каждой итерации будет считать смещение как i * stride + offset.
// Итерация по обычному массиву с данными getPosition: (d) => d.coordinates, getColor: (d) => d.color // Описание итератора по TypedArray getPosition: { value: buffer, type: 'float32', size: 3, offset: 0, stride: GPU_ITEM_SIZE }, getFillColor: { value: buffer, type: 'uint8', size: 4, offset: 12, stride: GPU_ITEM_SIZE }
Для получения координаты объекта он прочитает данные по полученному смещению используя соответствующий тип и столько раз, сколько указано в size. Довольно просто понять, как [lon, lat, alt] получается из первой строки, а [R, G, B, A] из второй, которые записаны в буфере друг за другом.
Вот так, 16 байт |LNG |LAT |ALT |R|G|B|A|
Пора реализовать подобный подход и посмотреть на результат.
function makeGPUBuffer(data: Point[]): PointDataBuffer { const buffer = new Float32Array(data.length * 4); const dataView = new DataView(buffer.buffer); let offset = 0; for (let i = 0; i < data.length; i++) { offset = i << 4; // i * 16 dataView.setFloat32(offset + 0, data[i].coordinates[0], true); dataView.setFloat32(offset + 4, data[i].coordinates[1], true); dataView.setFloat32(offset + 8, data[i].coordinates[2], true); dataView.setUint8(offset + 12, data[i].color[0]); dataView.setUint8(offset + 13, data[i].color[1]); dataView.setUint8(offset + 14, data[i].color[2]); dataView.setUint8(offset + 15, 255); } return { length: data.length / 4, attributes: { getPosition: { value: buffer, type: 'float32', size: 3, offset: 0, stride: GPU_ITEM_SIZE }, getFillColor: { value: buffer, type: 'uint8', size: 4, offset: 12, stride: GPU_ITEM_SIZE }, }, }; }
Обращу отдельное внимание на функцию DataView.setFloat (и все остальные, которые пишут значения размеров больше 1-го байта). Значения в GPU должны быть выравнены в LE, поэтому последним параметров это указывается. Теория о том, как наши девайсы, и не только, хранят многобайтовые величины.
Ну и результат.

FPS: 10 COUNT_OBJECTS: 100_000 ---- draw time: 0.1ms update time: 0.4ms
И продолжается бой
Абсолютные цифры, понятное дело, не имеют какого-то практического значения, важен рост производительности с условных 15ms до 0.5ms. Ну и приведу ещё список мероприятий, которые были сделаны в реальном проекте, но не могут являться частью статьи:
GPU буферы готовятся на бэкенде и передаются на UI в пригодном для рендеринга виде.
Буферы на UI не пересоздаются, как в примере, а делается точечное обновление объектов. Ведь не все они меняют координаты. А создание нового ArrayBuffer большого объёма, дело затратное, поскольку после выделения памяти он ещё и затирается нулями.
Объекты добавляются и удаляются со сцены, поэтому написан наследник Float32Array, который размечается с б`ольшим размером, чем необходимо и новые объекты добавляются в конец, а удаляемые просто затираются значением Infinity. Только по необходимости, при переполнении буфера, он перестраивается.
Сделана разбивка всей сцены на тайлы с помощью TileLayer, для того, чтобы не процессить объекты, которые не видны на сцене. Это, кстати, стандартный подход для картографических сервисов.
Весь процессинг, а он всё равно появляется, происходит в WebWorker`ах.
Данные ходят не по HTTP, а через WebSocket в двоичном виде, без всяких JSON.
Ссылки
Репозиторий из данной статьи.
Проекты-компаньоны deck.gl
