Реализация эффекта газетной фотографии на примере Quake
Около двух лет назад вышла игра Return of the Obra Dinn за авторством Лукаса Поупа. В ней была весьма интересная стилизация графики трёхмерного мира под графику старых монохромных компьютеров.
На Хабре даже выходил перевод поста автора данной игры, где он описывает, как работает этот эффект. Вкратце — изображение рисуется как обычно, освещается и затеняется, после чего делается монохромным с использованием матрицы Байера, натянутой на сферу расположенную вокруг камеры.
Мне понравилась сама стилизация под монохромный дисплей, но не понравился способ, которым автор данной игры это сделал. Он отверг способ наложения паттерна в пространстве текстуры, мне же такой способ показался наиболее интересным. Данный способ я попытался реализовать, о чём и будет текущая статья.
Основа эксперимента
Свою полноценную игру ради подобного эксперимента разрабатывать я совершенно не хотел. Даже полноценную демку со своим контентом было бы слишком долго делать. Поэтому я решил поэкспериментировать на уже существующей игре.
Как нельзя кстати, какое-то время назад я создал свой форк glQuake, немного улучшив код и добавив поддержку шейдеров. На основе него я и решил провести свои эксперименты.
Поиск нужного паттерна
В самом начале я просто подумал наложить ту же матрицу Байера как текстуру, используя текстурные координаты полигонов.
Результат выглядел любопытно, но шумновато:
Я добавил mip-текстурирование для матрицы Баера, результат улучшился, но всё равно, картинка оставалась весьма шумной:
Поразмыслив, я понял, что вообще, данный подход не может дать более-менее красиво выглядящего в динамике изображения, т. к. тексели не фильтруются и шумят. Я пришёл к выводу, что чтобы получить более-менее гладкое итоговое изображение, надо накладывать текстуру яркостного паттерна со сглаживанием. Это, конечно, не даст полностью монохромного изображения, но, если подумать, оно и не надо. Важен художественный эффект, а не честная монохромность.
Я стал искать паттерны, которые бы давали нужный эффект.
Сначала я попробовал штрихи, чем цвет темнее, тем больше штрихов:
Но штрихи мне не понравились, главным образом потому, что они не очень повторяют текстуры Quake по своей сути. Они выглядят не органично применительно к некоторым текстурам.
Я попробовал просто случайный шум:
Этот вариант выглядел получше, но всё равно, не был универсальным.
Ещё я экспериментировал со многими другими паттернами штриховки, и пришёл к выводу, что все они имеют ограниченную область применения — одни хороши для текстур дерева, другие — для камня или бетона, третьи — для органики, четвертые — для монстров и т. д.
Я допускаю, что если создать с десяток подобных материалоориентированых паттернов, то можно получить достаточно стильную картинку. Но, что самое важное, для этого и сами исходные текстуры нужно специальным образом подготовить. Если делать игру с нуля со своими ресурсами, то такой подход конечно можно использовать. Но меня такой подход не устраивал.
Паттерн кругов
В какой то момент я почти бросил эту затею, т. к. не смог придумать универсального и красиво выглядящего паттерна. Но, спустя почти что два года, меня вдруг осенило — я придумал такой паттерн.
Данный паттерн состоит из регулярных кругов, расположенных в узлах сетки.
Чем больше нужна яркость, тем больше круги. При достаточно высокой яркости круги пересекаются:
Этот паттерн мне навеяли старые чёрно-белые газеты, с тем отличием, что там были в основном чёрные круги на белом фоне, у меня же наоборот (почему так, см. ниже).
Паттерн мы составили, дальше встаёт вопрос, а как его применить? Применяется он весьма просто. Набор паттернов от самых тёмных (маленькие круги) до самых светлых (большие круги) объединяется в 3d-текстуру (или массив 2d текстур). Данная текстура передаётся во фрагментный шейдер, который осуществляет рисование геометрии с освещением. В шейдере происходят обычные вычисления как если бы описанных эффект не применялся — делается выборка из текстуры и светокарты, высчитывается итоговый цвет. Далее определённым способом вычисляется яркость получившегося цвета и приводится в диапазон [0; 1]. Далее наступает самая интересная часть.
По яркости выбирается необходимый слой в 3d текстуре паттерна, после чего делается выборка из этой текстуры. Результат этой выборки используется как окончательный цвет фргагмента. Для большей гладкости/плавности можно делать не одну выборку, а две — для двух ближайших слоёв в 3d текстуре паттерна и плавно интерполировать значения между ними. В итоге получается следующая картинка:
Хорошее начало, но над этим надо ещё поработать. Вдалеке видно, что круги становятся совсем мелкими и сливаются в шумящий узор. Чтобы такого не происходило, нужны mip-уровни. Реализуем mip-уровни, как если бы это была обычная текстура:
Выглядит лучше, но всё-же не достаточно хорошо. Проблема в том, что узор вдалеке сливается и превращается в серое месиво. Такой результат нас не устраивает. Чтобы узор не пропадал на расстоянии, mip-уровни должны содержать круги одинакового размера (в пикселях), т. е. содержать меньше кругов, чем нулевой mip-уровень. Теперь картинка выглядит следующим образом:
Уже лучше. Но теперь проблема в том, что круги более детальных mip-уровней теперь расположены по четыре штуки внутри кругов менее детального mip-уровня. Чтобы такого не было, надо несколько изменить местоположение кругов — на половину радиуса:
Теперь при приближении к текстуре круги уменьшаются, а между ними появляются новые круги. Ещё можно повернуть паттерн на 45 градусов, так, кажется, эффект лучше смотрится:
Эффект смотрится уже весьма прилично. Осталось настроить яркость как надо, реализовать эффект для турбулентных поверхностей (воды, лавы, телепортов), неба:
Сам эффект работает. Осталось несколько штрихов.
Немного стилизуем HUD и меню. Для этого эффект кругов не очень подходит, поэтому просто сделаем их монохромными и дискретизируем яркость:
Применим тонирование итогового изображения:
Окончательно откалибруем яркость:
Для того, чтобы геометрия вдалеке лучше выделялась, добавим обводку рёбер:
Данная обводка реализована через постпроцесс с использованием буфера глубины.
О рассчёте яркости
У меня получилась следующая формула:
brightness = clamp( 0.0, 1.0, 1.5 * mix( max( max( c.r, c.g ), c.b ), dot( c.rgb, vec3( 0.299, 0.587, 0.114 ) ), 0.5 ) );
Берётся среднее значение между яркостью максимального компонента цвета и усреднённой (с коэффициентами) яркости, после чего она домножается на 1.5. Данная формула подобрана исходя из условий конкретно игры Quake, в ваших условиях формула может быть другой.
О подготовке текстуры кругов
Сами круги не должны быть большими, 8×8 — вполне достаточно. Главное, рисовать их сглаженными. Размер круга надо вычислить исходя из нужной яркости. До радиуса ½ размера ячейки этот размер пропорционален корню от яркости. Круги с радиусом ½ дают яркость в Pi / 4 ~ 0.785.
При радиусе больше ½ круги начинают пересекаться и формула вычисления их яркости становится более сложной. Я не стал с нею заморачиваться и дальше сделал просто линейную зависимость оставшегося радиуса от яркости. Эта линейная зависимость не вполне точна, но удовлетворительна.
Именно из-за этой нелинейности приходится делать паттерн именно в форме белых кругов на чёрном фоне, а не наоборот. Игра Quake весьма мрачная, большинство поверхностей тёмные, поэтому на них будут рисоваться круги с точно подсчитанной яркостью, неточность же остаётся на немногочисленных ярких поверхностях вроде фонарей и лавы.
Немного технической информации
Текстура паттерна у меня вышла размером 256×256 x 16 уровней яркости + мип-уровни. При использовании одноканального 8-битного формата для её хранения требуется примерно 1.34 мегабайта памяти.
Вычислительная сложность эффекта не очень высока. Требуется дополнительная выборка во фрагментном шейдере из 3d текстуры (или 2 выборки из массива 2d текстур). Также требуется немного простых вычислений.
Для того, чтобы эффект выглядел лучше, советую применять к текстуре анизотропную фильтрацию от 4x и выше. Без оной эффект выглядит не очень красиво на поверхностях, находящихся под острым углом к линии взгляда.
Исходя из всего вышеизложенного, думаю, что данный эффект можно применять даже на мобильных телефонах, их вычислительных мощностей должно для него хватить.
Итоговый результат
Также я сделал видео, но видеокодеки Youtube сходят с ума от высокочастотных деталей, поэтому качество видео получилось весьма плохое.
Возможные улучшения
- Для дисплеев высокого разрешения может быть целесообразным сделать текстуру почётче — с кругами 16×16 пикселей, вместо 8×8.
- Можно несколько изменить форму точек. Например, делать плавный переход от белых точек на чёрном фоне к чёрным точкам на белом фоне.
- Можно попытаться реализовать цветной узор. Это потребует разложение цвета на 4 типографских краски, 4 выборки (вместо одной) из текстуры паттернна, каждая со своим поворотом текстурных координат, смешивания получившихся цветов особым образом.
- Как я уже описал выше, можно добиться других эффектов, просто поменяв алгоритм генерации текстуры паттерна, не меняя при этом шейдеров.
Заключение
По моему личному мнению, поставленной задачи мне удалось добиться, эффект получился весьма интересным и относительно несложным в реализации. Также в процессе воплощения данного эффекта были найдены способы реализовать сходные эффекты, что, я надеюсь, кто-нибудь сделает, вдохновившись данной статьёй.
Ссылки
Исходный код доступен на Github. Код выглядит страшно, т. к. это эксперимент, но почерпнуть необходимую информацию (при желании) из него можно.