Skip to content

Vaynerov Technologies

Мы не просто разрабатываем — мы создаём каждую строку кода и пиксель.

Все статьиРазборы

Орбитальная механика в браузере: Project Orrery

Vaynerov Project Orrery рисует каждый действующий спутник в реальном времени, во вкладке браузера, без плагинов и без сервера, который считал бы за него. Вот как на самом деле устроен конвейер — и какие его части, как выяснилось, не имеют к орбитальной механике никакого отношения.

Vaynerov TechnologiesСтудия
Опубликовано Обновлено 10 мин чтения
На этой странице
  1. Десятимегабайтный файл и двухмегабайтный кеш
  2. Кеплер плюс единственное возмущение, которое не пропустить
  3. Распространение визуального класса — и признание в этом
  4. Десять снимков в секунду, ноль аллокаций
  5. Один меш, шесть чисел на спутник
  6. Выбор объекта без рейкастера
  7. Береговая линия в 66 717 байт

Исходный файл весит около десяти мегабайт. В нём примерно шестнадцать тысяч записей орбитальных элементов — по одной на каждый действующий объект публичного каталога, — которые публикует CelesTrak и обновляет каждые пару часов. Vaynerov Project Orrery берёт этот файл, ужимает его, отправляет вам в браузер и распространяет все орбиты в Web Worker по десять снимков в секунду, пока вы крутите планету большим пальцем.

Есть версия этой страницы попроще. Вставить чужой трекер спутников в iframe, обвести бирюзовой рамкой, назвать флагманом. Мы вместо этого написали пропагатор — уравнение Кеплера, вековые скорости от J2, звёздное вращение Земли, всё на TypeScript. Ради этого решения страница и существует, и оно же, задним числом, оказалось наименее трудной частью работы. Трудными оказались кеш и бюджет памяти.

~16k
записей элементов на входе (~10 МБ)
~540 KB
после ужимания и gzip
5
итераций Ньютона на решение
0.010°
дрейф GEO за 6 модельных часов

Что приходит, что уезжает к вам, во что обходится решатель и чего требует от него самопроверка.

Четыре стадии, один бюджет аллокаций. Всё, что слева от воркера, происходит раз в шесть часов; всё, что справа, — десять раз в секунду.

Десятимегабайтный файл и двухмегабайтный кеш

Наш прокси просит у CelesTrak группу active в JSON, с таймаутом в шестьдесят секунд и user agent, который говорит, кто мы такие, — VaynerovOrrery/1.0 (+https://vaynerov.com/orrery), — потому что CelesTrak просит вызывающих представляться, а быть хорошим гостем ничего не стоит.

Обратно приходит многословный JSON с длинными человекочитаемыми ключами, повторёнными шестнадцать тысяч раз. Мы перекодируем его в массив массивов, что стирает ключи целиком, и округляем поля до точности, которой глаз реально может воспользоваться: среднее движение до восьми знаков после запятой, эксцентриситет до семи, углы до четырёх. Десять мегабайт превращаются примерно в полтора мегабайта сырыми и около 540 КБ по проводу.

// One satellite, ten slots. The name survives; everything else is a number.
// [name, norad, epochMs, meanMotion, ecc, inc, raan, argp, ma, groupIdx]

Ужатая запись. Выброшенные ключи JSON — самая крупная экономия во всём конвейере, крупнее любого округления.

Небольшая доля записей активно враждебна рендереру, и прокси их выбрасывает: всё с неконечным элементом, всё со средним движением ниже 0,9 оборота в сутки и всё с эксцентриситетом от 0,9 и выше. Порог по среднему движению убирает объекты на лунных расстояниях — дальние обсерватории, чьи орбиты увели бы их на несколько экранов за пределы планеты высотой в 6,4 единицы. Потолок по эксцентриситету убирает почти параболические записи, которые решатель Кеплера не любит по причине, к которой мы ещё вернёмся. До браузера доживает примерно одиннадцать тысяч записей.

Дальше деталь, которую легко сделать неправильно и которая молчит, когда сделана неправильно: эпохи общих возмущений публикуются без часового пояса. Date.parse читает метку без пояса как местное время, поэтому один и тот же каталог, распространённый на двух машинах в двух поясах, ставит каждый спутник в разное место — с расхождением в тысячи километров и без единого исключения. Мы обрезаем дробные секунды до миллисекунд и приписываем литеральный Z.

Почему кеш написан руками

У Next.js есть вполне приличный кеш данных, и здесь мы им не пользуемся по трём причинам, накопившимся по порядку. Первая — размер: десятимегабайтный ответ сверху превышает лимит в 2 МБ на запись, поэтому next: { revalidate } молча так и не сохранял последнюю удачную копию. «Молча» тут ключевое слово: ничего не падало, просто каждый запрос уходил наверх.

Вторая причина — та, что действительно ударила. ISR на уровне маршрута однажды закешировал 503 сверху, и, поскольку ответ был успешно закеширован, наше состояние «созвездие офлайн» замерло на странице на шесть часов, пока CelesTrak был совершенно здоров. Закешировать отказ хуже, чем не кешировать вовсе.

Третья — тонкость протокола: CelesTrak возвращает 403, если спросить снова внутри его двухчасового окна обновления. Это не ошибка, это сигнал не изменилось, и любой кеш, который примет его за отказ, вытеснит хорошие данные ради пустоты.

Устаревшие элементы лучше пустого неба. Спутник, нарисованный по шестичасовым элементам, для глаза всё равно ровно там, где он и есть.

Итак: два уровня, память процесса перед файлом во временном каталоге ОС, TTL в шесть часов против примерно двухчасового ритма CelesTrak, минимум десять минут между неудачными попытками и дедупликация запросов в полёте, чтобы холодный старт под нагрузкой сходил наверх один раз, а не по разу на запрос. Когда мы отдаём с устаревшего уровня, мы говорим об этом в заголовке ответа x-orrery-elements: stale, а браузерный кеш получает max-age=3600, stale-while-revalidate=21600. Настоящий офлайн — нет копии в памяти, нет копии на диске, источник лежит — возвращает 503 с пометкой no-store, чтобы нигде и ничто не смогло приколотить отказ так, как это когда-то сделал ISR.

Кеплер плюс единственное возмущение, которое не пропустить

Каждая запись даёт вам среднее движение — обороты в сутки — и пять углов. Механика двух тел превращает первое в большую полуось одной строкой, потому что среднее движение и размер орбиты — один и тот же факт, записанный дважды.

Земля, к сожалению, не точечная масса и не шар. На экваторе она выпирает примерно на 21 км, и это вздутие — закодированное безразмерным коэффициентом J2, 1,08262668 × 10⁻³ — раскручивает моментом любую орбиту, кроме строго экваториальной. За один виток эффект невидим. За неделю это разница между «спутник там, где вы его нарисовали» и «спутник по другую сторону планеты». Именно из-за J2 вообще существуют солнечно-синхронные орбиты: проектировщики миссий подбирают наклонение так, чтобы дрейф узла точно совпал с движением Земли вокруг Солнца.

const n0 = meanMotion * TWO_PI / 86400;      // rad/s
const a = Math.cbrt(MU_EARTH / (n0 * n0));   // km
const p = a * (1 - ecc * ecc);
const f = n0 * J2 * (RE / p) ** 2;

const raanDot = -1.5 * f * cosI;
const argpDot = 0.75 * f * (5 * cosI * cosI - 1);
const mDotJ2 = 0.75 * f * Math.sqrt(1 - ecc * ecc) * (3 * cosI * cosI - 1);

Три вековые скорости. Прямое восхождение восходящего узла отступает, аргумент перигея поворачивается, а средняя аномалия получает небольшую поправку — и всё это линейно по времени от эпохи записи.

Доведите три угла до запрошенного момента — и получите среднюю аномалию, которую надо превратить в эксцентрическую. Это уравнение Кеплера, M = E − e sin E, у которого нет решения в замкнутой форме и которое решают итерациями Ньютона. Мы стартуем с E = M и делаем ровно пять итераций — без проверки сходимости, без раннего выхода, без спорной границы цикла. Эта константа не лень; это дивиденд от фильтра по эксцентриситету наверху конвейера. При гарантированном e < 0,9 пять итераций от E = M укладываются во float-точность для каждой записи каталога, а фиксированное число итераций означает, что все спутники стоят одинаково, — именно этого и хочет плотный внутренний цикл по шестнадцати тысячам объектов.

Дальше одни повороты: перифокальные координаты через Rz(−Ω) Rx(−i) Rz(−ω) в геоцентрическую инерциальную систему, расписанные вручную, потому что контакт с двумерной плоскостью орбиты переживают лишь два столбца этого произведения. Потом одна перестановка осей, потому что в аэрокосмической традиции вверх смотрит Z, а в three.js — Y.

Из выбора системы отсчёта следует решение, которое стоит проговорить прямо: мы вращаем планету, а не созвездие. Спутники остаются в инерциальных координатах, где их орбиты просты, а группу Земли крутит среднее гринвичское звёздное время — θ = 280,46061837 + 360,98564736629 × (JD − 2451545,0). В учебнике за этим идёт ещё член с T²; мы его выбросили, измерив вклад: заметно меньше десятой доли градуса за десятилетия модельного времени. В масштабе планеты высотой 6,4 единицы десятая доля градуса — не пиксель.

Распространение визуального класса — и признание в этом

То, что мы построили, — это движение двух тел плюс вековые скорости J2. Здесь нет атмосферного торможения, нет лунно-солнечных возмущений, нет давления солнечного излучения, нет резонансных членов, нет короткопериодических. Для спутника на орбите высотой 400 км одно только торможение обгонит нашу модель за считаные дни.

Комментарий в пропагаторе говорит это одной фразой — честно для глаза в масштабе планеты высотой 6,4 единицы, а не для прибора — и заканчивается строкой, которая понравилась нам настолько, что мы вынесли её в интерфейс: никогда по нему не навигируйте. В HUD висит чип «распространение визуального класса», а карточка выбранного спутника сообщает, насколько стары его элементы, отмеряя это по модельному времени, а не по настенным часам, — так что перемотка вперёд честно ухудшает вид экстраполяции.

Модель, которая знает, чем она не является, полезнее модели, которая притворяется. Чип — не дисклеймер, который нас заставили написать; это самая точная надпись на странице.

Та же честность проявляется в месте помельче. Когда вы выбираете спутник, мы рисуем его орбиту как 128 выборок за один полный период, посчитанных точно и в главном потоке. Эта кривая верна только для того мгновения, в которое её посчитали, потому что J2 тихо поворачивает орбиту под ней, — поэтому через 45 минут модельного дрейфа её пересчитывают, а не оставляют красиво врать.

Ничего из этого нельзя было бы проверить, появляйся числа только в виде пикселей. Поэтому на старте выполняется самопроверка, живущая только в разработке, и она утверждает известную физику: среднее движение класса МКС в 15,495 оборота в сутки обязано давать большую полуось 6796,32 км, высоту 416,83 км, период 92,933 минуты и скорость 7,660 км/с; геостационарная запись с 1,0027 обязана давать 42 165,23 км и дрейф не более 0,010° за шесть часов. Если арифметика когда-нибудь уплывёт, мы услышим об этом из консоли раньше, чем кто-нибудь услышит с неба.

Десять снимков в секунду, ноль аллокаций

Одиннадцать тысяч решений Кеплера на кадр при 60 fps — не то, за что главному потоку стоит браться, и не то, за что ему нужно браться. Пропагатор живёт в Web Worker, свободном по контракту от DOM и от React, и выдаёт снимки с потолком в десять герц. Рендерер интерполирует.

Снимок — это один упакованный Float32Array, по три float на спутник, и он передаётся, а не копируется: владение уходит в главный поток, а ссылка воркера обнуляется. Движок держит два последних снимка и делает между ними lerp по модельному времени — поэтому физика на 10 Гц рисуется непрерывным движением. Когда буфер отслужил, главный поток передаёт его обратно воркеру сообщением на переработку, и воркер заполняет его заново. В установившемся режиме вся система не выделяет ровным счётом ничего; мусора нет, значит, нет и паузы сборщика, которую можно было бы почувствовать.

Время здесь чистая функция: модельное время — это якорь плюс прошедшее реальное время, умноженное на коэффициент перемотки, с режимами 1×, 60×, 600× и 3600× и возвратом к «сейчас». На большой перемотке воркера просят смотреть дальше вперёд — с опережением минимум в 1500 миллисекунд, и больше, когда перемотка велика, — потому что на 3600× нам приятнее плавный ход, чем точная интерполяция между двумя мгновениями, которые глаз всё равно не различит. Иллюзию берегут три стража: счётчик поколений выбрасывает снимки, сделанные до подмены каталога; прыжок времени назад больше порога, масштабированного перемоткой, аннулирует интерполяционный интервал вместо того, чтобы играть его задом наперёд; и весь цикл встаёт, когда документ скрыт или канвас занимает меньше 2% экрана.

Один меш, шесть чисел на спутник

Каждый спутник в небе — это один инстанс единственного InstancedMesh, построенного из тетраэдра: четыре треугольника, самое дешёвое замкнутое тело, — с неосвещаемым материалом и цветом на инстанс. Постобработки в Orrery нет нигде: проход bloom сделал бы небо красивее на скриншотах и съел бы кадровый бюджет каждого слабого устройства, которому это небо рисовать.

Обновление на кадр пишет прямо в массив матриц инстансов, и пишет шесть чисел на спутник из шестнадцати.

m[i + 0] = m[i + 5] = m[i + 10] = s;  // scale, on the diagonal
m[i + 12] = x;                        // translation
m[i + 13] = y;
m[i + 14] = z;
// rotation is never written; m[i + 15] was set to 1 once, at init.

Никаких объектов Matrix4, никакого вызова compose(), никаких временных объектов на инстанс. Одиннадцать тысяч таких записей на кадр — это копирование памяти, а не вычисление.

Видимый размер компенсируется по расстоянию, чтобы спутник у дальнего края Земли оставался кликабельным: s = 0.016 + dist × 0.0021, с потолком 0,105. Пилотируемые станции рисуются в 1,7 раза крупнее и дышат медленной синусоидой, которая при prefers-reduced-motion пропускается целиком. Фильтрация по группам — восемь корзин по принципу «первое совпадение выигрывает», которые назначает прокси, причём геостационар определяется средним движением между 0,99 и 1,02, чтобы метеоспутник, припаркованный над экватором, был помечен тем, чем он является, — не пересобирает меш никогда. Скрытым инстансам просто ставится нулевой масштаб, который GPU отбрасывает бесплатно.

Выбор объекта без рейкастера

Пускать луч в InstancedMesh из одиннадцати тысяч тетраэдров, чтобы найти тот, что под вашим пальцем, — это очень много проверок пересечения ради вопроса, на который матрица проекции уже ответила. Поэтому выбор идёт в обратную сторону: один цикл проецирует каждый спутник в экранные координаты, отсекает всё за камерой по знаку w, выбрасывает всё за пределами нормализованных координат устройства ±1,1 и оставляет ближайшее попадание в пределах 14 пикселей от мыши или 20 от кончика пальца. Это один проход арифметики, который мы всё равно собирались сделать.

Дальше касания получают привычную аккуратную возню: указатель, проехавший больше 8 пикселей, — это перетаскивание, а не тап; два тапа в пределах 350 миллисекунд и 24 пикселей означают «лететь вместе», что жёстко сдвигает камеру на собственную покадровую дельту спутника и гасит остаток, а нижняя граница зума при этом уезжает с 8 единиц до 0,9, чтобы к выбранному объекту можно было по-настоящему приблизиться.

Береговая линия в 66 717 байт

Глобусу нужны континенты, и обычный ответ на это — текстура: пара сотен килобайт JPEG, которые выглядят неправильно на любом приближении и расплываются в грязь на телефоне с низким DPR. Мы вместо этого нарисовали линии. Береговые линии Natural Earth 1:110m, квантованные до сотой доли градуса и упакованные длинами серий, превращаются в модуль из 134 полилиний и 5127 точек весом 66 717 байт — меньше маленькой фотографии и резко на любом масштабе.

Одна тонкость оправдывает свой код. Длинный участок береговой линии, нарисованный прямой хордой между двумя точками на сфере, проходит сквозь сферу, и Гренландия ненадолго тонет. Поэтому каждый сегмент разбивается вдоль большого круга сферической интерполяцией, пока шаг не станет меньше примерно двух градусов, — и тогда каждая вершина лежит ровно на поверхности. Тот же приём для градусной сетки, для более яркого экватора и для пунктирного геостационарного пояса на 42,164 единицы сцены — единственного кольца на странице, радиус которого вы можете посмотреть и проверить.

Одна единица сцены — это 1000 км. Оболочка низких орбит липнет к планете; геостационарный пояс сидит на 42,164 единицы — поэтому камера по умолчанию и начинает достаточно далеко, чтобы вместить обоих.

Orrery вышел в том же релизе, что и Blueprint, в день, когда в Лаборатории стало двенадцать игр, и он наследует этим играм больше, чем кажется: уровни качества, ограничивающие плотность пикселей по классу железа, контракт полноэкранного режима, дисциплину сноса каждого цикла при размонтировании. У браузерной игры и живой карты спутников, как выяснилось, почти одинаковые режимы отказа.