3D-режим отображения маршрутных сетей авиакомпаний на основе Three.js

В этой статье разберём устройство модуля src/view/composables/draw-airlines-3d: архитектуру, технологии, алгоритмы построения геометрии и подходы к оптимизации.
Технологический стек
Для 3D-рендеринга выбран Three.js — де-факто стандарт для WebGL в браузере. Он даёт удобные абстракции над сырым WebGL: сцена, камера, объекты, материалы, шейдеры — без необходимости писать всё с нуля.
Дополнительно используются:
- topojson-client — разбор геоданных в формате TopoJSON (карта мира)
- Earcut (встроен в Three.js) — триангуляция полигонов для построения меша суши
- OrbitControls из
three/examples/jsm— управление камерой мышью/тачем
Фронтенд, как и прежде, написан на Vue 3 + TypeScript.
Структура модуля
Модуль состоит из двух файлов и одного датасета:
draw-airlines-3d/
├── index.ts — Vue-composable, оркестрация сцены
└── scene.ts — построители геометрии и GLSL-шейдеры
Разделение намеренное: scene.ts — чистые функции без реактивности, только геометрия. index.ts — связывает Three.js с реактивными данными Vue, управляет жизненным циклом сцены.
Построение глобуса

Глобус — это не один объект, а стек из нескольких независимых слоёв, каждый из которых отрисовывается поверх предыдущего.
Океан
Базовая сфера с радиусом 1.0 и кастомным GLSL-шейдером, задающим вертикальный градиент цвета:
glsl
// fragment shader (упрощённо)
vec3 colorNorth = vec3(2.0/255.0, 26.0/255.0, 74.0/255.0);
vec3 colorSouth = vec3(9.0/255.0, 1.0/255.0, 24.0/255.0);
gl_FragColor = vec4(mix(colorSouth, colorNorth, vUv.y), 1.0);
Северный полюс — синеватый, южный — почти чёрный фиолетовый. Простой приём, создающий ощущение глубины без сложного освещения.
Суша
Слой суши строится из TopoJSON-данных. Загружаются три уровня детализации:
| Уровень | Файл | Размер | Когда используется |
|---|---|---|---|
lo |
countries-110m | ~99 КБ | Глобальный вид |
mid |
countries-50m | ~1 МБ | Средний зум |
hi |
land-10m.json | ~3 МБ | Вблизи |
lo-уровень загружается синхронно при инициализации. mid — с нулевой задержкой через setTimeout. hi — через requestIdleCallback с таймаутом 4 секунды: браузер загрузит тяжёлые данные в момент простоя, не блокируя интерфейс.
Переключение уровней происходит автоматически по дистанции камеры до глобуса.
Триангуляция полигонов — нетривиальная задача на сфере. Стандартная 2D-триангуляция на плоских координатах ломается у антимеридиана и на сложных контурах. Решение:
- Для каждого полигона вычисляется центроид.
- Применяется гномоническая проекция относительно центроида — она переводит дуги большого круга в прямые линии.
- Запускается Earcut на спроецированных координатах.
- Полученные треугольники делятся рекурсивно: если сторона треугольника длиннее 5°, она делится пополам — это «прижимает» грани к поверхности сферы.
- Треугольники, пересекающие антимеридиан, отфильтровываются как невалидные.
Меш суши рендерится на радиусе 1.002 — чуть выше океана — с полупрозрачным белым материалом (opacity: 0.12).
Границы стран
Отдельный слой линий на радиусе 1.003. topojson.mesh() возвращает общие границы между полигонами, что автоматически исключает дублирование рёбер. Непрозрачность — 0.2.
Атмосфера
Сфера радиусом 1.12 (больше глобуса) с шейдером кромочного свечения:
glsl
float intensity = 1.0 - dot(vNormal, vec3(0.0, 0.0, 1.0));
gl_FragColor = vec4(0.3, 0.6, 1.0, intensity * 0.4);
Используется аддитивное смешивание (AdditiveBlending) — цвета суммируются, что даёт правдоподобный эффект рассеивания света на границе планеты.
Звёзды
1800 точек, равномерно распределённых на сфере радиусом от 28 до 46 единиц. Генерируются через равномерное случайное распределение на сфере.
Маршруты авиакомпаний
Дуги большого круга
Маршруты отрисовываются как дуги большого круга (great-circle arcs) — кратчайший путь между двумя точками на поверхности сферы. Именно так летят самолёты в реальности.
Для каждого маршрута (departure → arrival):
- Координаты аэропортов переводятся в 3D-векторы на единичной сфере через
latLonToVec3. - Вычисляется угловое расстояние между точками.
- Строятся 48 промежуточных точек вдоль дуги через сферическую интерполяцию (
slerp). - Каждая точка «поднимается» над поверхностью пропорционально длине маршрута:
typescript
const lift = ARC_LIFT * Math.sin(Math.PI * i / (segments - 1));
const r = ARC_BASE_R + arcLen * lift;
// ARC_BASE_R = 1.004, ARC_LIFT = 0.06
Длинные трансокеанские маршруты образуют заметную арку, короткие региональные перелёты почти прижаты к поверхности. Это не только красиво, но и функционально — разводит маршруты разной длины по высоте.
Обычные маршруты рисуются белым с прозрачностью 0.22. Выделенные маршруты (выбранная авиакомпания) — оранжевым (0xeb610b) с полной непрозрачностью.
Точки аэропортов
Аэропорты отображаются как облако точек (THREE.Points) на радиусе 1.007. Фиксированный размер 5px независимо от зума — это достигается отключением затухания размера с расстоянием (sizeAttenuation: false).
Выбранный аэропорт дополнительно получает маленькую оранжевую сферу диаметром 0.005, масштабируемую в зависимости от расстояния камеры — чтобы визуальный размер маркера оставался постоянным при любом зуме.
Интерактивность
Управление камерой
OrbitControls с включённым enableDamping — вращение, масштабирование и панорамирование с инерцией. Скорость вращения и масштабирования адаптируется к текущему расстоянию до глобуса.
Ограничения:
- Минимальное расстояние:
1.15 * GLOBE_RADIUS— нельзя «провалиться» внутрь - Максимальное расстояние:
7 * GLOBE_RADIUS— нельзя улететь слишком далеко
При выборе авиакомпании камера автоматически подстраивается так, чтобы охватить все маршруты сети. Реализовано как плавная интерполяция (lerp с коэффициентом 7% за кадр) к целевой позиции.
Клик по аэропорту
Трёхмерный Raycaster для кликов по точкам был бы ненадёжен из-за их малого размера. Поэтому используется проекция: координаты каждого аэропорта переводятся в экранные пиксели через матрицу камеры, затем ищется ближайший аэропорт к позиции курсора в радиусе пикселей.
Лейблы
HTML-лейблы с названиями городов позиционируются через Vue: для каждого аэропорта вычисляется экранная позиция через проекцию Three.js, затем устанавливается через CSS transform: translate(). Аэропорты, смотрящие «от камеры» (обратная сторона глобуса), отфильтровываются через dot(cameraDir, cityNormal).
Архитектура Vue-composable
useDrawAirlines3d возвращает функции init, destroy и реактивные данные для лейблов. Весь жизненный цикл Three.js инкапсулирован внутри.
typescript
// Упрощённо
export function useDrawAirlines3d() {
const scene = new THREE.Scene()
const renderer = new THREE.WebGLRenderer({ antialias: true })
const camera = new THREE.PerspectiveCamera(45, aspect, 0.1, 100)
const controls = new OrbitControls(camera, renderer.domElement)
// Реактивность Vue → перестройка маршрутов
watch(selectedAirline, () => rebuildRoutes())
watch(selectedCity, () => rebuildHighlight())
// Рендер только по необходимости
controls.addEventListener('change', () => { needsRender = true })
function animate() {
requestAnimationFrame(animate)
if (controls.update() || needsRender) {
renderer.render(scene, camera)
needsRender = false
}
}
}
Рендер по требованию (needsRender флаг) — важная оптимизация. Браузер не тратит ресурсы GPU на перерисовку статичной сцены; рендер происходит только при взаимодействии или изменении данных.
Производительность
Несколько решений, существенно влияющих на плавность работы:
Кэширование геометрии LOD. Каждый уровень детализации суши строится один раз и переиспользуется при переключениях. Без кэша переключение уровня при зуме вызывало бы пересборку меша из 3 МБ JSON на каждый кадр.
Отложенная загрузка тяжёлых данных. Файл land-10m.json (~3 МБ) загружается через requestIdleCallback. Пользователь видит глобус немедленно с lo-геометрией, пока в фоне грузится детальная версия.
Рендер по требованию. Рендеринг запускается только при взаимодействии с OrbitControls или изменении выбранных данных — не на каждый requestAnimationFrame.
Переиспользование материалов. Однотипные объекты (все маршруты одного цвета) разделяют один материал, а не создают по экземпляру на каждую линию.
Итог
3D-режим добавляет принципиально другой способ восприятия маршрутных сетей: видно, как трансатлантические маршруты дугой уходят через север, насколько плотно покрыта Европа, где расположены хабы относительно друг друга. При этом технически это отдельный, изолированный модуль — 2D-режим продолжает работать независимо.
Интереснее всего в реализации оказалась геометрия: правильная триангуляция сферических полигонов с гномонической проекцией и Earcut, построение дуг большого круга с динамическим подъёмом — это тот класс задач, где «в лоб» не работает и нужно думать о природе данных.

Посмотреть в действии можно тут airlines.macrulez.ru, сверху справа есть переключатель режима отображения 2D | 3D