A screenshot of the "Escape Speed" game, featuring a small spaceship moving past a sign saying "Welcome to Origin!", with red debug lines overlayed

20 апреля xkcd опубликовал Escape Speed — четырнадцатый ежегодный комикс к Дню смеха, который мы разработали вместе. Escape Speed — это большая игра про исследование космоса, нарисованная Рэндалом Манро. Я писал код движка и редактора, а игровой логикой и обработкой ресурсов занимался davean. Карту игры редактировали Патрик КлэпЭмберКевинБенджамин Стаффин и Дженел Шейн.

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

Игра стала духовным наследником прошлогоднего комикса Gravity про исследование космоса. Наша цель заключалась в том, чтобы углубить игру, увеличив карту и добавив новые сложности с орбитальной механикой.

В статье мы расскажем несколько историй о разработке этих двух игр.


Сферические коровы

Существует старая физическая шутка об упрощении модели для облегчения расчётов [прим. пер.: в русскоязычной шутке обычно используется «сферический конь»]

В этом комиксе мы реализовали её буквально.

Когда в 2015 году мы делали Hoverboard, Рэндал принял умное решение, чтобы не рисовать анимацию цикла ходьбы: дал персонажу игрока ховерборд. Благодаря этому мы также избавились от кучи багов с коллизиями.

Работая над Gravity, я снова столкнулся с подобным затруднением. Вот цитата из нашего чата:

A screenshot of a Slack chat log transcript:
gentlemen, I've been thinking a lot about collisions
I've so far been imagining how the ship will bounce off of things in the environment, and what that should feel like
a scenario I really haven't liked is when the ship is traveling at a tangent to a planet and lands with a lot of lateral speed
if we naively auto rotate the ship upright when altitude is close to landing, it's gonna look really cheesy as the ship has to decelerate fast or weirdly slides across the surface
I also think it's kinda weird to have this indestructible ship which bounces elastically off everything
so, I had an idea, a wonderfully stupid hoverboard level idea
we give the ship a shield.
what if we drew a circle around the ship whenever there's collision contact around a fixed radius
suddenly all sorts of awkward collision or navigation problems disappear, AND it explains why this ship don't blow up
Джентльмены, я много думал о коллизиях. Я представлял, как корабль будет отскакивать от элементов окружения. Мне не нравится ситуация, в которой корабль движется по касательной к планете и приземляется с высокой горизонтальной скоростью. Если мы применим наивное решение и просто поставим корабль ровно, когда его высота будет близка к приземлению, то это будет выглядеть очень плохо, потому что ему придётся быстро тормозить или странно скользить по поверхности. К тому же я думаю: странно будет, что корабль неуязвим и упруго отталкивается от всего. Поэтому у меня появилась гениально-дурацкая идея — добавить кораблю щит. Что если при коллизии в заданном радиусе мы будем рисовать вокруг корабля круг? Так мы сразу устраним всевозможные неприятные коллизии и проблемы с навигацией. К тому же это объяснит, почему корабль не взрывается

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

Проектирование ориентации в пустом пространстве

Космос огромен и по большей части пуст. В отличие от движения на ховерборде, которое позволяет обнаруживать что-то новое, при произвольном перемещении в игре про космос игрок быстро потеряется:

reddit comments on Escape Speed:
Varandru: "I've heard somewhere that space travel somehow hits on both agoraphobia and claustrophobia simultaneously. Which is a neat fact if you're not travelling in space..."
bryancrain88: "This game gave me a greater fear of space than anything I've read or seen. The visceral sense of how easy it is to get lost in it."
Комментарии о Escape Speed с сайта reddit: Varandru: «Я где‑то слышал, что космические полёты одновременно приводят к обострению и агорафобии, и клаустрофобии. Это интересная информация, если, конечно, ты не путешествуешь в космосе...». bryancrain88: «Эта игра больше, чем что‑либо другое до этого, внушила мне страх космоса. Инстинктивное чувство того, насколько просто в нём потеряться».

Мы знали, что основной проблемой дизайна станет вопрос о том, как направлять игроков в геймплейно важные места. Рэндал предложил создать «компас», указывающий на ближайший ориентир. Эта идея эволюционировала в облако точек вокруг корабля с небольшими подсказками о расстоянии до объекта и его размере:

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

Затем мы добавили на карту знаки. Это дало игрокам понимание наличия важных для сюжета областей ещё до того, как они попадут в них.

Стеганография карты коллизий

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

Обычно мы прятали данные о коллизиях в самом младшем бите (least significant bit, LSB) одного из цветовых каналов. Когда Рэндал рисует карту, он использует сплошные цвета (обычно красный) для обозначения специальных проходимых областей, а мы обновляем цвета в своих скриптах обработки изображений.

Такое решение мы использовали для Hoverboard и Gravity. В каждом кадре мы рендерим пространство в непосредственной близости от игрока во внеэкранный canvas и проверяем значения LSB для определения проходимости (например, чётное = сплошная область, нечётное = проходимая). Можно выполнить в консоли JS ze.goggles(), чтобы увидеть эти скрытые canvas коллизий (с оверлеями отладки).

Screenshot of Hoverboard with the collision canvas debug view visible

Это работает вполне неплохо, но нужно быть аккуратным с антиалиасингом. Так как при изменении размера в изображение добавляются интерполированные значения в градациях серого, это может вызвать появление в изображениях неожиданных значений LSB.

В Hoverboard используется хак: при проверках LSB учитываются только тёмные пиксели (значения канала < 100). В Gravity мы хотели использовать и светлые, и тёмные проходимые области, поэтому davean написал собственный алгоритм изменения размеров изображений, сохраняющий значения LSB.

Crop of the Gravity sun source file from Gravity with passable pixels colored red

Также мы начали использовать в изображениях прозрачность, чтобы создать несколько наложенных друг на друга слоёв с движущимся фоном из звёзд. Хотя теоретически всё выглядело прекрасно, добавление альфы вызвало множество проблем. Мы постоянно находили области с фантомными коллизиями, которых не было на исходных изображениях. Потратив кучу времени на монотонную отладку и изучение пикселей, я обнаружил, что canvas использует во вспомогательном хранилище premultiplied alpha. Это может приводить к тому, что значения в canvas не совпадают с тем, что было записано.

Мы воспользовались аварийным планом, предложенным davean: рендерили данные о коллизиях в LSB альфа-канала (сам по себе альфа-канал не premultiplied). Это решение имело и свои проблемы (например, при объединении нескольких слоёв в canvas коллизий их значения альфы суммируются), но работало достаточно хорошо для выпуска готовой игры.

Потрясающий трюк с SVG, который испортил Safari

Для Escape Speed мы хотели придумать более удобное решение с картами коллизий. При использовании альфа-канала постоянно возникала проблема того, что некоторые закодированные значения могли быть совсем немного прозрачными. Я надеялся обрабатывать данные изображений на стороне клиента, чтобы исправлять значения альфы (например, округлять 254 до 255), но на практике это оказалось неприемлемым с точки зрения производительности.

А что если мы сможем переопределить цветовые каналы при помощи CSS? Оказалось, что это возможно сделать при помощи фильтров SVG! Можно использовать матричное преобразование для сопоставления красного канала с яркостью, синего с альфой, а зелёный оставить для коллизий:

<filter id="bw" x="0" y="0" width="100%" height="100%">
  <feColorMatrix
    type="matrix"
    values="1 0 0 0 0
            1 0 0 0 0
            1 0 0 0 0
            0 0 1 0 0"
  />
</filter>

Вот как выглядят данные изображений при таком сопоставлении цветов:

Crop of the sun source file from Escape Speed colored blue and pink, and impassible elements colored white

Это было потрясающее решение. На ранних этапах разработки оно работало прекрасно. К сожалению, при дальнейшем тестировании выяснилось, что фильтры SVG недопустимо медленны в Safari.

И нам снова нужно что-то придумывать…

A screenshot of a Slack chat log transcript:
davean: Goddard pushed with the change, will get geoverse rebuilding, but check goddard?
We can go back to that hack ... at least on safari
funny if safari gets the bad color
I can add a 4th image format to the processing
which direction did you want the LSB?
chromakode: lol plz no
davean: Already done and pushed
chromakode: are we even using black
davean: I don't even know what that means anymore
davean: Я могу добавить четвёртый формат изображения для обработки того, в каком направлении нужно изменять LSB? chromakode: lol, пожалуйста, не надо. davean: Уже сделал и запушил. chromakode: мы вообще используем чёрный? davean: Я уже не знаю, что это значит

Это было 29 марта, и в тот момент мы уже хотели реализовать то, что скорее всего будет работать. Чёрт с ней, с изящностью. Мы перешли на решение, которого избегали всё это время: к отдельным изображениям карт коллизий. Это удвоило объём скачиваемых изображений, но всё оказалось не так плохо: монохромные изображения хорошо сжимаются, а благодаря HTTP/2 или QUIC это меньше влияет на производительность, чем в наших играх с тайлами начала 2010-х.

Неожиданная ниша для TypeScript

Сложность наших игр возрастала, то же самое происходило и с сопутствующими массивами данных:

  • В Hoverboard имелся массив позиций монет

  • В Gravity был блоб JSON, описывающий местоположения и размеры планет

  • В Escape Speed добавили карту на TypeScript и IDE:

A screenshot of the editor for Escape Speed, with a game preview on the left with menus for customizing parameters, and a code editor on the right

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

  • Мы знали, что нам нужна проверка lint или валидация карты, чтобы отлавливать ошибки до попадания в продакшен.

  • Наш конвейер обработки ресурсов выводит данные JSON со всеми названиями слоёв и размерностями. Мы можем использовать это в типах, чтобы обеспечить редакторам обратную связь по возможным опциям и отслеживание опечаток в реальном времени!

    export type LocationName = keyof typeof imageData.locations
    export type LayerName<Name extends LocationName> =
      keyof (typeof imageData)['locations'][Name]['layers']
  • Простейший способ обеспечения удобного интерактивного редактирования — это добавление Monaco.

По сравнению с созданием валидатора в CI (медленная обратная связь при итерациях) или с визуальным редактором (огромный объём работы), TypeScript было легко освоить и достаточно просто с ним работать. Благодаря тому, что всё помещалось в веб-страницу, можно было крайне просто знакомить с системой новых сотрудников: им не приходилось клонировать репозиторий и устанавливать тулчейн, они одним щелчком получали доступ к удобному редактору кода.

В редакторе есть встроенный rollup для компиляции файлов карт в браузере и prettier для обеспечения неизменного стиля оформления кода. Меня очень удивило, что логичный путь привёл к созданию собственного IDE, и на самом деле уменьшил объём того, что нам пришлось разрабатывать!

Увеличивающиеся проблемы игрового движка: постоянные такты

Этот баг проявился так, как это происходит с самыми хитрыми багами: через наблюдение, казавшееся невозможным. Рэндал заметил, что у корабля почему-то тяга меньше, чем это было утром того же дня. Оказалось, что он тестировал игру в офисе, на экране своего графического планшета. Позже, при запуске десктопной версии, гравитация почему-то казалась неправильной. Многие люди проверили эту проблему, но не заметили разницы.

Рэндал недавно приобрёл M2 Macbook Pro с дисплеем, имеющим переменную частоту обновления 120 Гц. Ох, не может быть. Я протестировал баг на своём игровом мониторе и, разумеется, тоже его заметил.

Часть кода физики была взята ещё из Hoverboard, в которой такты игры синхронизировались с отрисовкой кадров. Чем больше кадров, тем быстрее такты физики. Однако накопив за восемь лет опыта, при работе над Gravity я дополнил этот код, чтобы учитывать дельту времени между кадрами. Теоретически, это компенсировало бы разницу во времени отрисовки кадров. Я изучил функцию тактов физики и устранил пару пропущенных ранее проблем, однако движение не улучшилось.

Я воспользовался глубокими знаниями davean по методикам программирования игр. Мы вместе изучили код в Discord. Ни один из нас не смог обнаружить проблем с формулами, пока мы не начали пошагово изучать состояние физики такт за тактом. В конце концов, мы выявили более глубокую проблему!

У корабля было специальное поведение при запуске из приземлённого состояния: он получает дополнительный прирост тяги. Хоть это и нереалистично, благодаря этому запуск казался более отзывчивым. Эта «скорость запуска» обеспечивала увеличение обычного ускорения в 2500 раз, но длилось это только один кадр. Даже с учётом дельты времени кадра интегратор физики был недостаточно точным, чтобы ускорение такой величины на один такт был одинаковым при 60 и 120 Гц.

Решение заключалось в переходе на постоянные такты. Вместо того, чтобы менять такты при обновлении кадров, мы используем постоянную частоту тактов физики в 120 Гц. При отрисовке кадров мы выполняем физические расчёты, чтобы они «догнали» текущее время с фиксированным шагом. Однако теперь, когда такты физики не синхронизированы с кадрами, мы вынуждены выполнять линейную интерполяцию позиции корабля, чтобы учесть кадр, происходящий между двумя тактами физики.

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

Одна из прекрасных особенностей возврата к старому коду заключается в демонстрации того, как изменилось твоё мышление. Изучая старый исходный код Hoverboard, я мог предугадать баги, которые упустил. Выделялись места, в которых я бы выбрал другие решения. Теперь я лучше понимаю эти проблемы, потому что у меня больше опыта. Возможно, когда-нибудь я так же взгляну и на Escape Speed.

Комментарии (0)