惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

V
Vulnerabilities – Threatpost
月光博客
月光博客
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
美团技术团队
Last Week in AI
Last Week in AI
Jina AI
Jina AI
P
Privacy International News Feed
有赞技术团队
有赞技术团队
WordPress大学
WordPress大学
宝玉的分享
宝玉的分享
T
Tenable Blog
阮一峰的网络日志
阮一峰的网络日志
P
Proofpoint News Feed
T
Tailwind CSS Blog
Apple Machine Learning Research
Apple Machine Learning Research
P
Privacy & Cybersecurity Law Blog
人人都是产品经理
人人都是产品经理
S
Schneier on Security
Google DeepMind News
Google DeepMind News
爱范儿
爱范儿
C
Cisco Blogs
K
Kaspersky official blog
C
Cybersecurity and Infrastructure Security Agency CISA
Hugging Face - Blog
Hugging Face - Blog
博客园_首页
O
OpenAI News
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
IT之家
IT之家
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
酷 壳 – CoolShell
酷 壳 – CoolShell
T
Tor Project blog
博客园 - 【当耐特】
腾讯CDC
V
V2EX
A
Arctic Wolf
Webroot Blog
Webroot Blog
S
Securelist
小众软件
小众软件
大猫的无限游戏
大猫的无限游戏
D
Darknet – Hacking Tools, Hacker News & Cyber Security
博客园 - 三生石上(FineUI控件)
The GitHub Blog
The GitHub Blog
量子位
J
Java Code Geeks
博客园 - 叶小钗
S
SegmentFault 最新的问题
Project Zero
Project Zero
www.infosecurity-magazine.com
www.infosecurity-magazine.com
Scott Helme
Scott Helme
Cyberwarzone
Cyberwarzone

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Как нарисовать тысячи объектов на карте и не убить FPS: эволюция рендера на Mapbox GL
Daniil Nuzhdin · 2026-06-24 · via Все публикации подряд на Хабре

7 мин

98

В прошлой статье про нетривиальные анимации я обещал показать «ту самую карту с облаками, zoom и движением по кривой Безье». Пришло время. Начну, правда, не с самой анимации, а с того, что было до неё: прежде чем что-то красиво анимировать на карте, надо это что-то на карте хотя бы нарисовать.

Контекст: я делал геолокационную соцсеть. На карте – «облака», посты, привязанные к точке на местности. Их может быть много. Очень много. И вся история ниже – про то, как я пять раз упёрся в производительность и что с каждым разом делал.

Наивный подход: одно облако – одна аннотация

Первое, что приходит в голову на любой карте (хоть MapKit, хоть Mapbox), – это аннотации. У тебя есть объект с координатой, ты создаёшь MGLAnnotation, отдаёшь карте addAnnotation, а в делегате возвращаешь вьюшку:

let annotation = MGLPointAnnotation()
annotation.coordinate = cloud.coordinate
mapView.addAnnotation(annotation)

func mapView(_ mapView: MGLMapView, viewFor annotation: MGLAnnotation) -> MGLAnnotationView? {
    let view = CloudAnnotationView(...)   // UIView с картинкой облака
    return view
}

Работает отлично – пока облаков десятки. Дальше начинается боль:

  • Каждая аннотация – это UIView в иерархии. Сотни и тысячи UIView, которые карта пытается позиционировать на каждый кадр движения, – это гарантированная просадка FPS.

  • При зуме/скролле карта дёргает делегат, пересоздаёт и перекладывает вьюшки. На «тысяче мест» это уже не карта, а слайд-шоу.

  • На старых устройствах (а в 2016–2018 это был добрый кусок аудитории) всё это умирало ещё раньше.

Тупик кластеризации

Очевидный ответ на «слишком много маркеров» – кластеризация: рядом стоящие точки схлопываем в один кружок с числом. Я честно прошёл этот путь дважды.

Сначала подключил ObjC-библиотеку кластеризации (GClusterManager и компания). Стало лучше, но всё равно тупило: каждый кластер – это снова аннотация с imageView. Когда я писал эту статью, я нашёл это в собственной истории коммитов:

«Новая кластеризация, которая не тупит. Теперь мест дофига может быть на карте»

То есть я выкинул первую библиотеку и взял вторую (поверх Google Maps Utils). Помогло, стало заметно лучше, но кластеризация имеет свою проблему: при кластеризации точки в группе получают одну координату. Есть два основных способа расставлять кластеры на карте: это может быть центр прямоугольника и это может быть центр масс материальных точек – в данном случае это чаты на карте. Можно задавать разные веса, можно одинаковые, но в любом случае мы не видим самых главных вещей: где именно на карте точно сосредоточены чаты и где наибольшая активность. Ещё одна проблема кластеров в текущей реализации – это всё те же аннотации, то есть всё те же UIView.

Перелом: рисуем не аннотациями, а GL-слоями

У Mapbox (тогда – MGL, Mapbox GL Native) есть style layers: MGLCircleStyleLayer, MGLSymbolStyleLayer, MGLHeatmapStyleLayer. Ты не создаёшь объект на каждую точку – ты отдаёшь карте источник данных (MGLShapeSource с коллекцией фич) и один слой, который описывает, как эти точки рисовать. Дальше всё уходит на GPU.

Коммит, который всё развернул, называется буквально: «Теперь ВСЕ маркеры являются частями MGLSymbolStyleLayer». Следом – «Удалил всё, что связано с GoogleMaps»: минус полторы тысячи строк ObjC-кластеринга, CloudsMarker, CloodsClusterRender. Аннотация-UIView осталась ровно одна – маркер текущего пользователя.

Принцип:

// один источник на тысячи точек
let source = MGLShapeSource(identifier: "clouds", features: features, options: nil)
style.addSource(source)

// один слой, который описывает КАК рисовать – остальное на GPU
let layer = MGLSymbolStyleLayer(identifier: "clouds-icons", source: source)
layer.iconImageName = NSExpression(forKeyPath: "icon")   // data-driven: имя картинки берётся из самой фичи
layer.iconAllowsOverlap = NSExpression(forConstantValue: true)
style.addLayer(layer)

Ключевое слово – data-driven. Иконка, размер, цвет – это не свойство «на весь слой», а выражение (NSExpression), которое для каждой точки вычисляется из её данных и из текущего зума. Один слой обслуживает тысячи точек.

Три движка рендера на разных зумах

Дальше выяснилось, что одного способа рисовать недостаточно. То, что хорошо выглядит на зуме города, бессмысленно на зуме страны, и наоборот. Поэтому облако рисуется тремя разными способами в зависимости от зума:

  • далёкий зум (< 9)MGLHeatmapStyleLayer, тепловая карта плотности. Никаких отдельных иконок: на масштабе страны человеку важно «где густо», а не каждая точка;

  • средний зум (9–12)MGLCircleStyleLayer, простые круги;

  • близкий зум (≥ 12)MGLSymbolStyleLayer, детальные иконки облаков.

Пороги – обычные константы:

static let smallCirclesZoomLevel: Float = 9.0
static let circlesZoomLevel:      Float = 12.0

А переключение между движками – это не «if zoom < 9», выполняемый на CPU каждый кадр. Это снова выражение, которое гасит прозрачность слоя на нужном диапазоне зумов, и считает его GPU:

heatmapLayer.heatmapOpacity = NSExpression(
    format: "FUNCTION($zoomLevel, 'mgl_interpolateWithCurveType:parameters:stops:', 'linear', null, %@)",
    [ Self.smallCirclesZoomLevel: 1,
      Self.smallCirclesZoomLevel + 0.2: 0 ]   // выше этого зума heatmap плавно исчезает
)

Перенос zoom-зависимости с CPU на GPU-выражения (mgl_interpolate…/mgl_step…) был отдельным коммитом – и отдельным скачком плавности.

Главная фишка: круги постоянного РЕАЛЬНОГО размера

У каждого облака есть «аура» – радиус оповещения, нарисованный кругом на карте. Здесь принципиальный момент: радиус этого круга задан в метрах на местности, а не в пикселях на экране. То есть при зуме круг не остаётся одного и того же размера на экране (как обычная точка), а ведёт себя как реальный объект на земле – приближаешь карту, и круг растёт вместе с улицами под ним.

Как это сделать, если circleRadius у слоя задаётся в пикселях? Через тот же приём – интерполяцию по зуму, но с предварительно посчитанными «метры → пиксели» на опорных зумах.

Сначала функция «сколько метров в одном пикселе» на данной широте и зуме (обычная Web-Mercator математика):

func metersPerPixel(at lat: CLLocationDegrees, zoom: Float) -> CLLocationDistance {
    let worldSize = pow(2.0, Double(zoom)) * 512          // размер мира в пикселях на этом зуме
    return cos(lat * .pi / 180) * 2 * .pi * 6_378_137 / worldSize
}

Дальше – берём размер ауры в метрах, переводим его в пиксели на двух опорных зумах (2 и 15) и отдаём карте экспоненциальную интерполяцию между ними:

let meters = CloodsUtils.auraRadiusBy(cloud: cloud)       // размер ауры в МЕТРАХ

let rPx2  = Double(meters) / metersPerPixel(at: lat, zoom: 2)
let rPx15 = Double(meters) / metersPerPixel(at: lat, zoom: 15)

// радиус круга в пикселях интерполируется по зуму так,
// что РЕАЛЬНЫЙ размер на местности остаётся постоянным
layer.circleRadius = NSExpression(
    format: "FUNCTION($zoomLevel, 'mgl_interpolateWithCurveType:parameters:stops:', 'exponential', 1.75, %@)",
    [  2: NSExpression(forConstantValue: rPx2),
      15: NSExpression(forConstantValue: rPx15),
      22: NSExpression(forConstantValue: rPx15) ]
)

И снова всё, что зависит от зума, считает GPU. Мы не пересчитываем радиусы кругов в Swift на каждый кадр движения карты – мы один раз описали закон, по которому они меняются.

Кстати, сам размер ауры в метрах – это не константа: он зависит от «веса» облака и от активности региона. В тихом регионе одно и то же облако получает ауру побольше, в шумном – поменьше, чтобы карта не превращалась в кашу из перекрывающихся кругов. Но это уже про данные, а не про рендер.

Анимируем то, что штатно не анимируется

Теперь та самая анимация, которую я обещал. Проблема: у UIView есть alpha, который Core Animation анимирует за тебя. У GL-слоя такого нет. circleOpacity – это NSExpression, а не анимируемое свойство. Нельзя написать UIView.animate { layer.opacity = 0 }.

Здесь и пригодился DisplayLinkAnimator из прошлой статьи – кадровый аниматор поверх CADisplayLink. Я анимирую прозрачность слоя сам, покадрово, переустанавливая константное выражение каждый кадр:

// кросс-фейд GL-слоя: каждый кадр пересобираем константное выражение opacity
animator.startAnimation(
    animationDuration: 0.25,
    animation: { [weak layer] _, progress in
        layer?.circleOpacity = NSExpression(forConstantValue: progress)   // 0 → 1
    },
    needCompleteAnimationAfterStopping: true   // гарантированно доведём до конечного состояния
)

Именно так сделан плавный переход между тремя движками рендера и появление/исчезновение аур: heatmap гаснет – круги проявляются, и наоборот. Сам механизм аниматора (ProMotion, timing-функции, синхронизация с обновлением экрана) я разбирал в прошлой статье, так что здесь не повторяюсь – важно, что он позволил анимировать то, у чего нет анимируемого свойства.

Ещё четыре оптимизации, без которых ничего не взлетело

Перевод на GL-слои был главным сдвигом, но не единственным. Коротко – остальное, что в сумме и дало плавность:

40 слоёв → 12. Сначала я держал по слою на каждый размер облака: ~20 слоёв под иконки и ~20 под круги. Плюс гонял геометрию в карту через JSONSerialization в geoJSON-строку на каждый апдейт. Коммит «вместо 20 слоёв для иконок теперь 2, вместо 20 слоёв для кругов – 10» свернул всё в data-driven выражения (forKeyPath: "icon", цвет viewed/normal через mgl_step) и убрал JSON-сериализацию – данные пошли прямо через MGLShapeSource(features:).

Рисуем не всё, а топ. Наивная ранняя установка «показывать ВСЕ облака – наверное, так быстрее» оказалась ровно наоборот. Появился клиентский QuadTree – пространственный индекс, который заменяет перебор всех точек спуском по дереву (и для тапа по облаку, и для выборки «кто сейчас в кадре»). Из найденных облаков рисуется только ограниченный топ: у меня это 15 аур в видимой области плюс до 100 «про запас» в расширенной зоне за краем экрана, чтобы они не выскакивали рывком при панорамировании. Число одновременно рисуемых объектов перестало зависеть от плотности данных. Как устроен сам индекс и как из тысяч облаков выбираются те самые «топовые» – отдельная следующая статья.

Дебаунс «первый + последний». При быстром зуме/скролле карта сыпала перекрывающимися сетевыми запросами облаков, а протухшие ответы перерисовывали уже неактуальную область. Решение: пока идёт загрузка, форсированный запрос откладывается, выполняется только последний – и только если его queryID ещё актуален. Вместо N запросов на жест – первый и последний.

Диффинг аур вместо пересоздания. Был баг: при движении карты ауры пересоздавались каждый апдейт и накапливались («облаков с аурами становилось всё больше»). Теперь showAuras считает set-разницу между тем, что уже нарисовано (lastCloudsWithAuras), и новым набором – и трогает только дельту: что появилось, что исчезло. Если дельта пустая – ранний выход, карта вообще ничего не пересобирает.

И поверх всего – тяжёлая перезагрузка облаков срабатывает не на каждый кадр движения (regionIsChanging), а только когда карта остановилась (regionDidChangeAnimated → idle). Во время самого движения обновляется лишь видимость уже существующих слоёв, без пересоздания.

Итог

Если выкинуть всю историю в одну мысль: на карте с тысячами объектов нельзя думать объектами UIKit. Каждый маркер как UIView-аннотация – это потолок в десятки точек. Как только рендер переезжает на GL-слои с data-driven выражениями, а зум-зависимость (размер, прозрачность, выбор движка) считает GPU, – карта перестаёт замечать количество точек. Дальше остаётся инженерная гигиена: не держать лишних слоёв, не пересоздавать то, что не изменилось, не слать запросы на каждый чих и грузить тяжёлое в фоне.

А «круги постоянного реального размера» через metersPerPixel + экспоненциальную интерполяцию – мой любимый приём из всего этого: один раз описываешь физический закон, и карта сама держит его на всех зумах )

В следующей части – тот самый клиентский QuadTree: как по тапу пальца за O(log n) понять, на какое из тысяч облаков ты попал, и как из всей массы выбрать те самые «топовые», что стоит показать.