
Я написал одну фразу: «Хочу на сайт самую реалистичную, но производительную визуализацию чёрной дыры — чтобы было просто ВАУ. Всё по формулам, с математической точностью». Claude Opus задал четыре вопроса, я ответил на них и дописал «сделай и задеплой». Через час с небольшим после того, как мы выбрали подход, на сайте появилась вращающаяся чёрная дыра: каждый её пиксель — фотон, прослеженный назад во времени через метрику Керра. Ниже — как это строилось, где всё ломалось и как чинилось.
Четыре вопроса и одна развилка
Сначала Opus не написал ни строчки кода — он уточнял. Что нужно: чистое шоу, лаборатория или шоу с объяснением? (Шоу с объяснением.) Каким делать диск: честно по физике — одна сторона в разы ярче и голубее — или «как в „Интерстелларе“», где эти эффекты сознательно убрали ради красоты? (Физика, но с выключателями: пусть видно, как из неё получается кино.) Можно ли упасть внутрь? (Конечно.)
Главная развилка была одна: точность или скорость. Честно трассировать лучи в метрике вращающейся дыры каждый кадр — дорого, на телефоне картинка станет мыльной. Посчитать заранее таблицу — быстро, но это работает только для невращающейся дыры, а тогда пропадает всё самое интересное: несимметричная «D-образная» тень, увлечение пространства, эргосфера.
Opus предложил третий путь и объяснил его одной фразой: пространство-время стационарно, диск тоже, значит путь луча через пиксель зависит только от камеры. Пока камера стоит, геометрию считать не нужно — достаточно раз в кадр перекрасить уже известные точки попадания: повернуть диск, учесть запаздывание света и доплеровский сдвиг. Это «карта лучей»: геометрия пересчитывается только при движении, анимация почти бесплатна.
Запомните этот аргумент — через полчаса я сам его сломаю.
Сначала физика, потом картинка
Самое правильное, что Opus сделал за вечер: он не бросился писать шейдер. Сначала появилась физика на обычном JavaScript в двойной точности: метрика Керра в координатах Керра–Шильда (они не ломаются на горизонте, так что свет можно вести и сквозь него), уравнения Гамильтона для луча, камера как ортонормированная тетрада, диск Новикова–Торна, цвет чёрного тела. И сразу тесты против того, что известно в замкнутой форме:
- граница тени против кривой Бардина 1973 года;
- критический прицельный параметр невращающейся дыры, √27 M;
- отклонение света вдали, 4M/b;
- последняя устойчивая орбита — 6 M без вращения и 1,2369 M при спине 0,998;
- поток Пейджа–Торна: численный интеграл против замкнутой формулы;
- цвет чёрного тела против локуса Планка из стандарта CIE;
- свободное падение против циклоиды.
Шейдер потом повторил эту физику строка в строку. Если что-то не сходится, сразу понятно, где искать.
Ошибка в одну мнимую единицу
Первым споткнулся переход между координатами. Чтобы нарисовать аналитическую тень поверх рендера, нужно переводить импульс фотона из координат Бойера–Линдквиста в координаты Керра–Шильда. Opus написал якобиан и тут же проверил его самым прямолинейным способом: пересчитал через него метрику и сравнил с учебной формой Керра. Смешанные компоненты gtr и grφ должны быть нулями — а вышли −0,019 и 1,12.
Дальше без гаданий: восемь комбинаций знаков, перебранных скриптом за секунду. Нули дала ровно одна — x + iy = (r + ia)·eiφ·sin θ, а не (r − ia), как было в коде. В литературе встречаются обе записи, и какую из них подразумевает конкретная форма метрики, проще проверить, чем вспомнить.
Лучи, которые стреляли из горизонта
После пары поправок в самих тестах все 13 проверок физики позеленели. Opus стал смотреть, сколько шагов нужно лучу, и заметил странное: у невращающейся дыры в статистике кадра не было ни одного «чёрного» пикселя. То есть тень есть, а лучей, упавших в дыру, — ноль.
Выяснилось: возле горизонта компоненты импульса в координатах Керра–Шильда растут экспоненциально, и метод Рунге–Кутты с фиксированным шагом просто «выстреливал» луч на небо — на расстояние 1015 M. Исправление оказалось физическим, а не численным. Самая внутренняя круговая фотонная орбита — точка невозврата: луч, который оказался внутри неё и летит внутрь, уже не развернётся. Это условие захвата заодно сэкономило шаги: в среднем лучу хватает 40–60.
У почти предельно вращающейся дыры (a = 0,998) нашлись и лучи, которые тысячами шагов наматываются у горизонта вместе с вращающимся пространством. Эталонный интегратор с контролем ошибки подтвердил, что они всё равно падают в дыру. Поэтому видеокарте разрешено сдаваться после 700 шагов и красить такой пиксель в чёрный — ответ тот же.
Первый кадр
Первый рендер получился с первой попытки — и это было честно красиво. Видны тень, дальняя половина диска, которую свет огибает сверху и снизу, яркая, приближающаяся к нам сторона. Opus смотрел на кадры сам: запускал Chrome без окна, но с настоящей видеокартой (M4 Pro через Metal), снимал скриншоты и склеивал их в контактные листы по десятку кадров.

На первом кадре нашлись шов текстуры на линии φ = ±π и пропавшее небо. Шов — классика: производные текстурных координат на скачке угла выбирают самый размытый мип-уровень. Небо пропадало из-за 404: локальный PHP-сервер раздавал файлы не из той папки.
Небо, которое перетянуло одеяло
Фон здесь — настоящее небо: панорама Млечного Пути NASA из раздела «Звёздное небо» и 9096 звёзд каталога ярких звёзд. Небо развёрнуто так, что за дырой оказывается центр Галактики. Когда оно наконец загрузилось, линза собрала центр Галактики в огромное светящееся кольцо Эйнштейна вокруг дыры, и оно перебило всё остальное (средний кадр выше).
Хуже было другое: звёзды превратились в штрихи. Для линзы панорама — протяжённый источник, и она его честно растягивает. Звёзды так вести себя не должны: точка остаётся точкой, только становится ярче. Решение из двух частей. Из панорамы убрали яркие звёзды, а на их место вернули звёзды каталога — как точки, с яркостью, умноженной на коэффициент усиления линзы μ = Ωпикселя / Ωследа на небе. Теперь звёзды у края тени честно вспыхивают и двоятся, и кратные изображения никто специально не рисует.

Первая чистка, кстати, вычистила вообще все звёзды — 32% пикселей панорамы — и небо стало пустым. Порог пришлось поднимать до «только то, что ярче каталожного предела», и под маску ушло 3% пикселей.
А потом случился мой любимый баг вечера. Яркость неба уменьшили вчетверо — картинка не изменилась. Opus не стал крутить ползунок дальше, а спросил у живой страницы, какие значения на самом деле видит рендерер: skyGain: 1. Начальное состояние просто ни разу не передавалось в рендерер — функцию применения настроек при старте не вызывали.
Дыра, съехавшая на телефоне
На экране телефона шириной 390 точек дыра упорно сидела у правого края. Геометрию камеры Opus перепроверять не стал: скрипт перебрал все элементы страницы и нашёл тот, что вылезает за ширину экрана. Им оказалась… таблица проверок в статье внизу страницы. Шириной 541 пиксель она раздвигала видимую область браузера до 561 точки, фиксированный холст растягивался вместе с ней, и центр кадра уезжал вправо. Таблица чёрной дыры сдвинула чёрную дыру.

«И с камерой поработай»
Пока шли проверки, я дописал: камера не должна стоять на месте, пока её не перехватили. Пусть будут пролёты над диском, отъезды — «супер операторская работа одним кадром». И падение тоже срежиссировать.
Это ломало главную оптимизацию: вся экономия держалась на неподвижной камере. Теперь геометрия пересчитывается каждый кадр. Ответ — адаптивное разрешение: рендерер следит за временем кадра и держит карту лучей такой, какую GPU успевает посчитать. На M4 Pro это от 40 до 100% разрешения экрана, в зависимости от плана.
Сам облёт — восемь ключевых планов, связанных кубическими кривыми Эрмита, чтобы камера нигде не останавливалась и не дёргалась. Там есть заход в плоскость диска, бреющий пролёт на высоте меньше гравитационного радиуса над его поверхностью, подъём, большой отъезд сверху и нырок под диск. У пути два физических правила: камера не заходит в эргосферу, где стоять на месте невозможно, и пересекает плоскость диска только за его внешним краем. Стоит тронуть экран — управление ваше. Через 15 секунд тишины оператор плавно забирает камеру обратно с того места, где вы её оставили.

С экспозицией тоже вышла ловушка. Автоэкспозиция по логарифмическому среднему, как у фотокамер, провалилась: огромная абсолютно чёрная тень тянула среднее вниз, и камера не прикрывала «диафрагму», а открывала. Помогло обычное среднее и запрет осветлять кадр сильнее исходного.
Падение, которое рассчитано заранее
Первое падение было честным, но скучным: камеру отпускали из покоя, и она летела по прямой к центру. Режиссированное устроено иначе. Перед стартом код страницы — его Opus написал для этого — перебирает боковой толчок от 0,2 скорости света вниз и для каждого просчитывает всю мировую линию заранее — это миллисекунды. Толчок отбраковывается, если камера улетает или проходит сквозь диск, и берётся самый сильный из тех, что всё-таки кончаются в дыре. Камеру закручивает вокруг дыры на 80–145°.
По пути оператор наклоняет взгляд к проносящемуся диску, заваливает горизонт по крену и перед горизонтом смотрит прямо в растущую тень. Пересекая горизонт, вы ничего не замечаете — там нет стены. Но через пару секунд камера сама разворачивается, и видно то, ради чего всё это стоило делать: уходящая Вселенная — звёзды, Млечный Путь и диск тонкой светящейся линией. У вращающейся дыры путь заканчивается у внутреннего горизонта: там падающее следом излучение неограниченно синеет, и дальше классическая теория уже не надёжна. Так и написано на экране.

Фотонное кольцо пунктиром
На крупном плане край тени оказался украшен светящимся пунктиром. Это фотонное кольцо — изображения диска, свет которых обогнул дыру на пол-оборота, на полтора и больше. Оно тоньше пикселя: одни пиксели в него попадают, другие нет. Однопиксельный фильтр не помог — промежутки между точками по 4–5 пикселей.
Помогло честное решение: адаптивный суперсэмплинг. Отдельный проход находит пиксели на границах (диск против тени, скачок между изображениями диска) и выпускает в каждый ещё 12 фотонов. В пиксель записывается представительное попадание и доля покрытия. В движении фотонов 4, и только если видеокарта успевает.

Проверка на честность
Два момента за вечер понравились мне больше всего.
Первый — проверка видеокарты. Отдельный скрипт открывает страницу в настоящем браузере, вычитывает из GPU всю карту лучей и перетрассирует случайные пиксели эталонным интегратором в двойной точности. Результат на четырёх ракурсах и спинах: тип пикселя (диск, небо, дыра) совпадает в 99,95–100% случаев, радиус попадания на диск — в пределах 0,4%, направление на небо — в пределах 0,1°. Тот же скрипт потом прогнали прямо против продакшена — тоже 100%.
Второй — английский перевод. Opus отдал его отдельному агенту-переводчику с правилом «факты заморожены, сомнения — в отчёт». Переводчик нашёл в тексте самого Opus две физические неточности. √(1 − 3M/r) — это не только подъём света из гравитационной ямы, но и замедление часов летящего газа. А наблюдатель с нулевым моментом импульса у вращающейся дыры всё-таки увлекается вращением пространства. Обе правки теперь в обеих версиях.

Что упрощено
Честный список есть в статье под визуализацией. Главное: диск бесконечно тонкий, без короны и джета, а сгустки турбулентности — иллюстрация. Профиль температуры, скорости и все смещения частоты при этом точные. Температура диска подобрана так, чтобы цвета были видимыми: настоящие диски горячее, светят в ультрафиолете и рентгене и выглядели бы однородно голубовато-белыми. Небо подсвечено, иначе рядом с диском звёзд не было бы видно.
Как Opus справился
Если убрать физику, запомнилось три вещи.
Сначала доказать, потом рисовать. Физика с тестами против замкнутых формул появилась раньше шейдера, поэтому каждая странность в картинке превращалась в вопрос «где расходится с эталоном», а не в гадание.
Смотреть своими глазами. Каждое изменение Opus проверял скриншотами из настоящего браузера с настоящей видеокартой — на десктопе, на экране телефона, в падении, по шагам тура. Больше половины найденных проблем видны только так: шов текстуры, пересвеченное небо, штрихи вместо звёзд, съехавшая дыра, пунктирное кольцо.
Не спорить с интуицией, а измерять. «Небо не тускнеет» — прочитать состояние живой страницы. «Дыра съехала» — найти элемент шире экрана. «Знак в якобиане» — перебрать все восемь вариантов.
Моё участие уместилось в несколько сообщений: ответы на четыре вопроса, «сделай и задеплой», «поработай с камерой» и «спрячь кнопку фидбэка». В итоге около 3900 строк кода, 13 тестов физики, тесты страницы, сверка видеокарты с эталоном и 60 КБ скрипта (24 КБ в gzip) без единой библиотеки для 3D.
Откройте чёрную дыру, подождите полминуты, пока оператор сделает круг, а потом нажмите «Упасть в дыру». Лучше на компьютере и в полноэкранном режиме.