image

Введение


Когда в январе 2019 года мы начали обсуждать нашу новую игру tint., то сразу решили, что важнейшим элементом будет эффект акварели. Вдохновлённые этой рекламой Bulgari, мы понимали, что реализация рисования акварелью должна соответствовать высокому качеству остальных ресурсов, которые мы планировали создать. Мы обнаружили интересную статью исследователей из Adobe(1). Описанная в ней техника создания акварели выглядела замечательно, а благодаря своей векторной (а не пиксельной) природе она могла работать даже на слабых мобильных устройствах. Наша реализация основана на этом исследовании, мы изменили и/или упростили отдельные его части, потому что наши требования к производительности были другими. tint. — это игра, поэтому кроме самого рисования нам нужно было в одном кадре рендерить всё 3D-окружение и выполнять игровую логику. Также мы стремились к тому, чтобы симуляция выполнялась в реальном времени и игрок сразу видел нарисованное.


Симуляция акварели в tint.

В этой статье мы поделимся отдельными подробностями реализации этой техники в игровом движке Unity и расскажем о том, как мы адаптировали её к беспроблемной работе на мобильных устройствах нижнего ценового уровня. Мы подробнее расскажем об основных этапах этого алгоритма, но без демонстрации кода. Данная реализация была создана в Unity 2018.4.2 и позже обновлена до версии 2018.4.7.

Что такое tint.?


Tint. — это игра-головоломка, позволяющая игроку проходить уровни, смешивая цвета акварели, чтобы они совпали с цветами оригами. Игра была выпущена осенью 2019 в Apple Arcade для iOS, macOS, и tvOS.


Скриншот tint.

Требования


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

  1. Генерация новых пятен на основании ввода игрока и добавление их в список пятен
  2. Симуляция краски для всех пятен в списке
  3. Рендеринг пятен

Ниже будет подробно рассказано о том, как мы реализовали каждый из этапов.

Мы стремились достичь 60 FPS, то есть эти этапы и вся описанная ниже логика выполняются 60 раз в секунду.

Получение ввода


В каждом кадре мы преобразуем ввод игрока (в зависимости от платформы это могут быть касание, позиция мыши или виртуального курсора) в структуру splatData, которая содержит позицию, вектор наклона движения, цвет и давление (2). Сначала мы проверяем длину свайпа игрока по экрану и сравниваем её с заданным пороговым значением. При коротких свайпах мы генерируем в позиции ввода по одному пятну за кадр. В противоположном случае мы заполняем расстояние между начальной и конечной точками свайпа игрока новыми пятнами, созданными с заранее заданной плотностью (это обеспечивает постоянную плотность краски вне зависимости от скорости свайпа). Цвет обозначает текущую используемую краску, а наклон движения — направление свайпа. Создаваемые новые пятна добавляются в коллекцию под названием splatList, которая также содержит все ранее созданные пятна. Она используется для симуляции и рендеринга краски на следующих этапах. Каждое отдельное пятно обозначает «каплю» краски, которую нужно отрендерить — основной строительный блок рисования акварелью. Готовый акварельный рисунок будет являются результатом рендеринга десятков/сотен пересекающихся пятен. Кроме того, только что созданному пятну назначается значение времени жизни (в кадрах), которое определяет, как долго может симулироваться пятно.


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

Холст


Как и в настоящем рисовании красками, нам нужен холст. Для его реализации мы создали в 3D-пространстве ограниченную область, которая внешне выглядит как лист бумаги. Координаты ввода игрока и все остальные операции, например, рендеринг меша, записываются в пространстве холста. Аналогично, размер в пикселях любого буфера, используемого для симуляции рисования, зависит от размера холста. Используемый в статье термин «холст» (canvas) никак не связан с классом Canvas из Unity UI.


Зелёным прямоугольником показана область холста в игре

Пятно


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


Примеры мешей пятен.

Для каждой вершины мы также храним направленный наружу вектор скорости, который затем используется на этапе симуляции. Мы генерируем несколько таких мешей с небольшими вариациями форм между ними и сохраняем их данные в скриптуемый объект (scriptable object). Каждый раз, когда игрок в реальном времени рисует пятно, мы назначаем ему меш, случайным образом выбранный из этого набора. Стоит упомянуть, что при разных разрешениях экрана холст имеет разный размер в пикселях. Чтобы на всех устройствах коэффициент размера пятен был одинаковым, при запуске игры мы изменяем масштаб в соответствии с размером холста.


Пример векторов пятна, хранимых вместе с данными нового пятна.

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


Заполнение Wetmap, пиксели внутри формы пятна (зелёный круг) помечают буфер wetmap (сетку) как мокрый (зелёный цвет). Сам буфер wetmap имеет гораздо большее разрешение.

Кроме того, каждое пятно также содержит значение непрозрачности (opacity), являющееся функцией от её площади; оно представляет эффект сохранения пигмента (постоянного количества пигмента в пятне). Когда при симуляции размер пятна увеличивается, его непрозрачность снижается, и наоборот.


Пример краски без адвекции (слева) и с ней (справа).


Примеры адвекции краски.

Цикл симуляции


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

В каждом кадре мы обходим список пятен и изменяем позиции всех вершин пятен при помощи следующего уравнения:


где: m — новый вектор движения, a — постоянный корректировочный параметр (0.33), b — вектор наклона движения = нормализованное направление свайпа игрока, умноженное на 0.3, cr — скалярная величина шероховатости холста = Random.Range(1,1 + r), r — глобальный параметр шероховатости, для стандартной краски мы задаём ему значение 0.4, v — вектор скорости, заранее созданный вместе с мешем пятна, vm — множитель скорости, скалярное значение, которое мы локально используем в некоторых ситуациях для ускорения адвекции, x(t+1) — потенциальная новая позиция вершины, x(t) — текущая позиция вершины, br — вектор шероховатости разветвления = (Random.Range(-r, r), Random.Range(-r, r)), w(x) — значение смачивания в буфере wetmap.

Результат таких уравнений называется случайным блужданием со смещением (biased random walk), он имитирует поведение частиц в реальной акварельной краске. Мы пытаемся переместить каждую вершину пятна наружу от его центра (v), добавляя случайности. Затем направление движения немного изменяется направлением штриха (b) и снова рандомизируется ещё одним компонентом шероховатости (br). Затем эта новая позиция вершины сравнивается с wetmap. Если холст в новой позиции уже был мокрым (значение в буфере wetmap больше 0), то мы задаём вершине новую позицию x(t+1), а в противном случае не изменяем её позицию. В результате краска будет растекаться только в те области холста, которые уже были мокрыми. На последнем этапе мы заново пересчитываем площадь пятна, которая используется в цикле рендеринга для изменения её непрозрачности.


Пример симуляции адвекции между двумя активными пятнами краски в микромасштабе.

Цикл рендеринга — буфер смачивания


После пересчёта пятен их можно начинать рендерить. На выходе после этапа эмуляции меши пятен часто оказываются деформированными (например, возникают взаимопересечения), поэтому для их правильного рендеринга без дополнительных затрат на повторную триангуляцию мы используем решение с двухпроходным стенсил-буфером. Для рендеринга пятен применяется интерфейс рисования Unity Graphics, а цикл рендеринга выполняется внутри метода Unity OnPostRender. Меши пятен рендерятся в render texture (wetBuffer) при помощи отдельной камеры. В начале цикла wetBuffer очищается и задаётся в качестве render target при помощи Graphics.SetRenderTarget(wetBuffer). Далее для каждого активного пятна из splatList мы выполняем последовательность, показанную на следующей схеме:


Схема цикла рендеринга.

Мы начинаем с очистки стенсил-буфера перед каждым пятном, чтобы на новое пятно не влияло состояние стенсил-буфера предыдущего пятна. Затем мы выбираем материал, используемый для отрисовки пятна. Этот материал отвечает за цвет пятна, и мы выбираем его на основании индекса цвета, сохранённого в splatData в момент рисования пятна игроком. Затем мы меняем непрозрачность цвета (альфа-канал) на основании площади меша пятна, вычисленной на предыдущем этапе. Сам рендеринг выполняется при помощи шейдера двухпроходного стенсил-буфера. В первом проходе (Material.SetPass(0)) мы передаём исходный меш пятна для записи тех координат, в которых меш заполнен. При этом проходе ColorMask присваивается значение 0, поэтому сам меш не рендерится. Во втором проходе (Material.SetPass(1)) мы используем четырёхугольник, описанный вокруг меша пятна. Мы проверяем значение в стенсил-буфере для каждого пикселя четырёхугольника; если значение равно единице, то пиксель рендерится, а в противном случае пропускается. В результате этой операции мы рендерим ту же фигуру, что и меш пятна, но в ней совершенно точно не будет содержаться нежелательных артефактов, например, самопересечений.


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


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

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


Шейдер холста: только wetBuffer (слева), добавлена текстура бумаги (по центру), добавлена карта нормалей (справа).

Игра поддерживает режим для людей с дальтонизмом, в котором поверх краски накладываются отдельные паттерны. Чтобы достичь этого, мы изменили материал пятен, добавив текстуру паттерна с тайлингом. Паттерны следуют правилам смешения цветов игры, например, синий (решётка) + жёлтый (круги) дают на пересечении зелёный (круги в решётке). Для бесшовного смешения паттернов они должны рендериться в одном UV-пространстве. Мы настраиваем UV-координаты четырёхугольника, используемого во втором проходе стенсил-буфера, деля значения позиций x и y (которые заданы в пространстве холста) на ширину и высоту холста. В результате мы получаем правильные значения u,v в пространстве от 0 до 1.


Пример паттернов режима цветовой слепоты.

Оптимизация — буфер высохших пятен


Как говорилось выше, одной из наших задач была поддержка маломощных мобильных устройств. Рендеринг пятен оказался узким местом нашей игры. Каждое пятно требует трёх вызовов отрисовки (вызовы двух проходов + очистка стенсил-буфера), и поскольку линия краски содержит десятки или сотни пятен, количество вызовов отрисовки быстро нарастает и приводит к падению частоты кадров. Чтобы справиться с этим, мы применили две техники оптимизации: во-первых, одновременная отрисовка всех «высохших» пятен в dryBuffer, во-вторых, локальное ускорение высыхания пятна после достижения определённого количества активных пятен.

dryBuffer — это дополнительная render texture, добавляемая в цикл рендеринга. Как говорилось ранее, каждое пятно имеет срок жизни (в кадрах), который уменьшается с каждым кадром. После того, как срок жизни достигает 0, пятно считается «высохшим». «Сухие» пятна больше не симулируются, их форма не меняется, а потому их не нужно повторно рендерить в каждом кадре.


DryBuffer в действии; серым цветом показаны пятна, скопированные в dryBuffer.

Каждое пятно, срок жизни которого достиг 0, удаляется из splatList и «копируется» в dryBuffer. В процессе копирования повторно используется цикл рендеринга, и на этот раз в качестве целевой render texture задаётся dryBuffer.

Правильного смешивания между wetBuffer и dryBuffer невозможно достичь простым взаимным наложением буферов в шейдере холста, потому что render texture буфера wetBuffer содержит пятна, уже отрендеренные с альфа-значением (что эквивалентно premultiplied alpha). Мы обошли эту проблему, добавив в начало цикла рендеринга один этап перед итеративным обходом пятен. На этом этапе мы рендерим четырёхугольник размером с пирамиду усечения камеры, который отображает dryBuffer. Благодаря этому любое пятно, которое рендерится в wetBuffer будет уже смешано с сухими, ранее нарисованными пятнами.


Смешение мокрых и высохших пятен.

Буфер dryBuffer накапливает все «высохшие» пятна и не очищается между кадрами. Поэтому всю память, которая связана с пятнами с истёкшим сроком жизни, можно очистить после их «копирования» в буфер.


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

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

Чтобы обеспечить стабильную частоту кадров, мы изменили алгоритм так, чтобы количество активных пятен было ограничено постоянным значением maxActiveSplats. Все пятна, превышающие это значение, мгновенно «высыхают». Это реализовано снижением срока жизни самых старых активных пятен до 0, из-за чего они копируются в буфер высохших пятен раньше. Поскольку при уменьшении срока жизни мы получим пятно в незавершённом состоянии симуляции (которое будет выглядеть довольно интересно), в то же самое время мы увеличиваем скорость растекания краски. Благодаря увеличению скорости пятно достигает почти такого же размера, что и при обычной скорости со стандартным сроком жизни.


Демонстрация максимальных 40 (сверху) и 80 (снизу) активных пятен. Серым показаны «высохшие» пятна, скопированные в dryBuffer. Величина обозначает «количество» краски, которая может симулироваться одновременно.

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

Заключение


Реализация этого алгоритма стала интересной и сложной задачей. Надеемся, читателям понравилась статья. Можете задавать вопросы в комментариях к оригиналу. Если вы хотите оценить нашу акварель в действии, то попробуйте сыграть в tint. на Apple Arcade.


Скриншот игры, запущенной на Apple TV

(1) S. DiVerdi, A. Krishnaswamy, R. MAch and D. Ito, “Painting with Polygons: A Procedural Watercolor Engine,” in IEEE Transactions on Visualization and Computer Graphics, vol. 19, no. 5, pp. 723–735, May 2013. doi: 10.1109/TVCG.2012.295

(2) Давление учитывается только при рисовании Apple Pencil на iPad.