Marshmallow Sky - постмортем

Сверху. Графически с последней части изменилось немного​
Сверху. Графически с последней части изменилось немного​
Внутри.
Внутри.

В основном про воксели, трассировку лучей, процедурную генерацию и немного про физику полета.

Высота = скорость

Это игра про планирование. Не то, которое plan, а то, которое полет, glide. Летаем над, а по большей части сквозь процедурно генерируемый ландшафт и собираем черные жемчужины, которые нас немного ускоряют и позволяют лететь дальше. Еще есть возможность увеличить или уменьшить скорость кликами мыши, но ускорение будет стоить нам одной жемчужины.

Реиграбельность достигается процедурно генерируемым ландшафтом, которые бесконечен, и кроме того постоянно в движении - каждый следующий момент он уже немного , но полностью будет отличаться от предыдущего состояния.

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

Посмотреть на код -

Поиграть - http://marshmallow-sky.netlify.com

Либо опять же скачать с гитхаба и запустить release/index.html

Лирика

Планирование (которое полет) мне в играх нравилось давно. А конкретно, после игры Gat Out of Hell. Главным недостатком игры было то, что городок в игре маленький и облететь его всего можно часов за пять максимум. Я искал такого же ощущения в других играх (например, в Аркхамах), но как-то все было не совсем то. Возможно, дело в том, что обычно в них гораздо больше скорости и, соответственно ниже управляемость.

Поэтому давно хотел сделать что-то такое самому. С процедурно генерируемыми уровнями, разумеется. Как раз на неделе начал экспериментировать с процедурной генерацией. И вдруг - хакатон, и тема как раз в тему. Под эту тему решил сделать уже и геймплей.

Физика

Физика тут самое интересное, причем обе - физика света и физика полета. Ну и еще есть генерация уровня - не совсем физика, но тоже интересно. С неё и начнем.

Шум

В основе генерации лежит 3d simplex noise. Взят он отсюда

Шум имеет амплитуду от 0 до 1. Если для какой-то точки значение шума больше некоторого значения, то в ней рисуется воксель, если нет - нет. Дополнительно из шума вычитается высота (у меня - z координата), так что чем выше, тем разреженнее получается пространство. Результат выглядит так:

Marshmallow Sky - постмортем

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

Луковая структура изнутри не настолько очевидна, но на срезе хорошо видна.
Луковая структура изнутри не настолько очевидна, но на срезе хорошо видна.

А еще можно его сделать динамическим. Например, двигать постепенно вверх.

Не так быстро, конечно

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

К этому моменту вскрылся один неприятный момент. Как оказалось, считать 3d шум - это дорого по времени. Поэтому было решено сделать то, как обычно в таких случаях и делают - запечь шум в текстуру. Примем именно 3d текстуру, потому что это позволит автоматически линейно интерполировать значения между пикселями.

Слева - текстура шума 200*200*200, справа - 50*50*50
Слева - текстура шума 200*200*200, справа - 50*50*50

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

А вот так выглядит результат с текстурой шума 200*200*200, но без линейной аппроксимации.
А вот так выглядит результат с текстурой шума 200*200*200, но без линейной аппроксимации.

Вообщем, на этом о генерации уровня почти всё. Есть еще пара деталей, но о них позже.

Графика

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

Не знаю, как точно она называется, но я видел её применение неоднократно. В частности, в примерах к TWGL.

Идея такая. Геометрия (т.е. вершинный шейдер) делается так: пространство рубится квадратами вдоль каждой оси через постоянный интервал, равный "толщине" вокселя. Получается своеобразная трехмерная "решетка".

Примерно вот так ​
Примерно вот так ​

Таким образом, сторона каждого вокселя лежит на каком-либо из этих квадратах. И уже "раскрашивая" квадраты во фрагментном шейдере мы решаем, нужно ли что-то тут нарисовать (т.е. есть ли там воксель), и если да, то каким цветом.

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

Если приглядеться, можно увидеть плоскости, на которые рубится пространство.
Если приглядеться, можно увидеть плоскости, на которые рубится пространство.

Почему же я не использовал "метод решетки" (интересно всё-таки, как же он на самом деле называется) в игре?

Дело в том, что несмотря на то, что полигонов при этом используется очень мало, они ОГРОМНЫЕ. Каждая точка на экране перекрывается обычно большей их частью. А перерисовывать каждый пиксель экрана по 500, например, раз, как оказалось, довольно медленно.

Есть у этого недостатка и обратная сторона. При росте количества вокселей время отрисовки растет довольно медленно. Пропорционально числу плоскостей, т.е. кубическому корню от числа вокселей.

Миллиард вокселей - где-то 3 FPS. Всё ещё без рейтрейсинга.
Миллиард вокселей - где-то 3 FPS. Всё ещё без рейтрейсинга.

Кстати, я пытался отрисовать даже триллион вокселей. На небольшом экране получается нечто примерно то же, что и при миллиарде. А в 1920*1080 драйвер видеокарты уходит в перегрузку, так что я особо с этим не экспериментировал.

Я это все показывал по ходу дело на дискорде Procjam-а (сам Procjam, кстати, был в начале месяца, и я изначально подумывал всем вот этим заниматься как раз на нем, но заявленная тема джема вдохновила на нечто совершенно иное. Впрочем сейчас не об этом). И в какой-то момент меня спросили "это SDF?".

Что такое SDF я не знал, но знал про raymarching и по контексту понял, что это как-то связано. И еще я покуривал периодически исходники на shadertoy и видел, что ничего сверхъестественно сложного в этом нет.

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

И где-то в час утра субботы получилось то, с чего, наверное, изучают raymarching почти все - изображение шара.

Marshmallow Sky - постмортем

А когда я разобрался, как считать нормали (тоже, кстати, очень просто), смог даже отобразить довольно хтоничную анимацию моей "луковички"

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

Первый день ушел на работу с графикой. Основной проблемой было то, что "в глубине" было довольно трудно ориентироваться.

Пробовал разные расцветки и штриховки.

Например, такую.
Например, такую.

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

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

float shadowCast = raycast(pos + normal * 2., vec3(0.3, 0.2, 1.), 50); if(shadowCast < 300. && shadowCast > .1) color *= 0.3;

В результате получилась "зефирно - коральная" картинка, которая и осталась до релиза. Меня она вполне устраивала.

Оптимизация

При всех своих достоинствах raymarching, все-таки, довольно небыстрый алгоритм. Трудно подобрать правильную длину шага. Шаг слишком длинный - и луч будет "пробивать" тонкие поверхности.

Хотя это и может выглядеть красиво.
Хотя это и может выглядеть красиво.

Слишком маленький шаг - луч либо будет считаться долго, либо не будет доставать до поверхности. Дело осложнялось тем, что то, что я называл у себя "sdf", т.е, функцией, равной расстоянию до ближайшей поверхности, было на него похоже довольно отдаленно.

Больше всего проблем доставляли верхние слои "луковицы", где толщина пластин сходила в ноль.

Небо в результате выглядело так:

Marshmallow Sky - постмортем

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

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

Метание жемчуга

Теперь надо было расставить предметы для сбора. Изначально планировались рисовать звездочки по "традиционной" полигональной технологии. Но для сделать так, чтобы "лучевой" и полигональные алгоритм одинаково считали глубину, оказалось нетривиально, а время шло. Поэтому вместо полигональных звезд сделал жемчужины через ту же трассировку лучей. Заодно вырезал вокруг каждой жемчужины часть пространства, так как было некрасиво, когда она частично утопала в стене.

Расстановка делается так: плоскость XY расчерчивается на квадраты, в центре каждого ставится одна жемчужина на случайной высоте. При рейтрейсинге проверяется только жемчужина в текущем квадрате, что не совсем точно, но как правило работает.

Не совсем тривиальным было и то, как удалять "съеденные" жемчужины. Так как их число бесконечно, то нельзя было просто завести их массив и "вычеркивать" в нем собранные. Решил я это так: жемчужин стало конечное число (1000), но каждая находится в бесконечном числе мест. Проще говоря, я присвоил каждой жемчужине хэш-сумму от 0 до 1000, и в массиве длиной 1000 помечал как удаленный именно этот хэш. В игре такой "хак" почти незаметен.

Скорость = 0.3 высоты

Наконец, дело дошло собственно до физики полета. Она, конечно, получилась очень упрощенной. Куда смотрим, туда летим. Скорость постепенно падает. Смена высоты переводится в скорость с коэфициентом 0.3, т.е. спуск на 10 метров дает +3 м/c скорости. Есть некоторое ограничение на скорость поворота (которое зависит от FPS, что, конечно же, баг), но скорость при поворотах не падает (что, наверное, тоже надо исправить).

Еще планировалась отмотка времени, например, при врезании в стену. Но она бажила, поэтому при врезании просто штрафую на 5 жемчужин и отправляю на высоту 300.

Вообщем, на этом почти все. Добавил UI, зашил щейдеры в HTML, чтобы можно было без проблем запускать офлайн, и отправил.

Литература и инструменты

Использовалась библиотека TWGL

и генератор шума отсюда

Из литературы вдохновлялся в основном этим

но если интересно можно просто погуглить sdf raymarching.

P.S.

Забыл про одну деталь написать, Danil S напомнил. Как считается столкновение со стеной и с жемчужиной. Это тоже делается в шейдере. Если присмотреться, то в игре на экране самый нижний левый пиксел "битый". На самом деле он используется для обратной связи. Когда видеокарта его обсчитывает, то вместо стандартной процедуры трейсинга, она пишет в красный цвет 1, если мы "в стене", а в зеленый - хэш жемчужины в радиусе 10 метров. Помимо всего прочего, это позволяет избежать дублирования кода, например, расчета хэш кода жемчужины, наличия стены и т.д.

88
13 комментариев

Комментарий недоступен

1
Ответить

Спасибо. 

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

1
Ответить

Математика очень впечатляет. Хотя мало что понятно.

1
Ответить

Если хочется понимания, можно почитать какую-нибудь базовую статью про SDF трассировку, если интересны подробности. Например, эту http://jamie-wong.com/2016/07/15/ray-marching-signed-distance-functions/

И, возможно, про simplex noise, если непонятно про генерацию. Там действительно все довольно просто. Я решил в статье это не разжевывать, чтобы не грузить.

Ответить

Завораживающее зрелище :)

1
Ответить

очень интересная статья, с примерами и картинками.
но я вообще ничего не понял(
но автор молодец, что пишет постмортемы, это идеальный инструмент коммуникации с коллегами и единомышленниками.

Ответить