[Перевод] Свёрточные текстуры

Самостоятельно обновляемые текстуры


Когда существует возможность распараллеливания симуляций или задач рендеринга, то обычно лучше всего выполнять их в GPU. В этой статье я объясню технику, использующую этот факт для создания впечатляющих визуальных трюков с низкими затратами производительности. Все эффекты, которые я продемонстрирую, реализованы при помощи текстур, которые при обновлении »рендерятся сами в себя»; текстура обновляется при рендеринге нового кадра, а следующее состояние текстуры полностью зависит от предыдущего состояния. На этих текстурах можно рисовать, вызывающая определённые изменения, а сама текстура прямо или косвенно может применяться для рендеринга интересных анимаций. Я называю их свёрточными текстурами.

756a1a0051471cbe895a8883d89efadd.jpg


Рисунок 1: двойная буферизация свёрточной текстуры

Прежде чем двигаться дальше, нам нужно решить одну проблему: текстуру нельзя считывать и записывать одновременно, такие графические API, как OpenGL и DirectX, не позволяют этого делать. Так как следующее состояние текстуры зависит от предыдущего, нам нужно как-то обойти это ограничение. Мне нужно выполнять чтение из другой текстуры, а не из той, в которой выполняется запись.

Решением является двойная буферизация. На рисунке 1 показано, как она работает: на самом деле вместо одной текстуры есть две, но в одну из них выполняется запись, а из другой производится чтение. Текстура, в которую выполняется запись, называется back buffer (вторичный буфер), а рендерящаяся текстура — front buffer (первичный буфер). Поскольку свёрточная тестура «записывается в саму себя», вторичный буфер в каждом кадре выполняет запись в первичный буфер, а затем первичный рендерится или используется для рендеринга. В следующем кадре роли меняются и предыдущий первичный буфер используется как источник для следующего первичного буфера.
Благодаря рендерингу предыдущего состояния в новое свёрточная текстура при помощи фрагментного шейдера (fragment shader) (или пиксельного шейдера ) обеспечивает интересные эффекты и анимации. Шейдер определяет, как изменяется состояние. Исходный код всех примеров из статьи (а также других) можно найти в репозитории на GitHub.

Простые примеры применения


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

  • Если у живой ячейки меньше двух соседей, но она становится мёртвой.
  • Если у живой ячейки два или три живых соседа, она остаётся живой.
  • Если у живой ячейки более трёх живых соседей, то она становится мёртвой.
  • Если у мёртвой ячейки три живых соседа, она становится живой.


Для реализации этой игры как свёрточной текстуры, я интерпретирую текстуру как сетку игры, а шейдер выполняет рендеринг на основании изложенных выше праавил. Прозрачный пиксель — это мёртвая ячейка, а белый непрозрачный пиксель — живая. Интерактивная реализация показана ниже. Для получения доступа к GPU я использую myr.js, для которого требуется WebGL 2. Самые современные браузеры (например, Chrome и Firefox) могут работать с ним, но если демо не работает, то скорее всего браузер его не поддерживает. Используйте мышь (или сенсорный экран) [в оригинале статьи] для рисования на текстуре живых ячеек.


Код фрагментного шейдера (на GLSL, потому что для рендеринга я использую WebGL) показан ниже. Сначала я реализую функцию get, которая позволяет мне считать пиксель из определённого смещения от текущего. Переменная pixelSize — это заранее созданный 2D-вектор, содержащий UV-смещение каждого пикселя, а функция get использует его для считывания соседней ячейки. Затем функция main определяет новый цвет ячейки на основании текущего состояния (live) и количества живых соседей.

uniform sampler2D source;
uniform lowp vec2 pixelSize;

in mediump vec2 uv;

layout (location = 0) out lowp vec4 color;

int get(int dx, int dy) {
    return int(texture(source, uv + pixelSize * vec2(dx, dy)).r);
}

void main() {
    int live = get(0, 0);
    int neighbors =
        get(-1, -1) +
        get(0, -1) +
        get(1, -1) +
        get(-1, 0) +
        get(1, 0) +
        get(-1, 1) +
        get(0, 1) +
        get(1, 1);
    if (live == 1 && neighbors < 2)
        color = vec4(0);
    else if (live == 1 && (neighbors == 2 || neighbors == 3))
        color = vec4(1);
    else if (live == 1 && neighbors == 3)
        color = vec4(0);
    else if (live == 0 && neighbors == 3)
        color = vec4(1);
    else
        color = vec4(0);
}


Ещё одна простая свёрточная текстура — это игра с падающим песком, в которой пользователь может бросать в сцену разноцветный песок, который падает вниз и образует горы. Хотя её реализация чуть сложнее, правила проще:

  • Если под песчинкой нет песка, то она падает на один пиксель вниз.
  • Если под песчинкой есть песок, но она может соскользнуть вниз на 45 градусов влево или вправо, то так она и поступит.


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


Ещё один шаг вперёд


Рисунок 2: пиксель волны.

Представленные выше примеры используют свёрточную текстуру напрямую; её содержимое рендерится на экран, как есть. Если интерпретировать изображения только как пиксели, то пределы использования этой техники сильно ограничены, но благодаря современному оборудованию их можно расширить. Вместо того, чтобы считать пиксели цветами, я буду интерпретировать их немного иначе, что можно использовать для создания анимаций ещё одной текстуры или 3D-модели.

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

Я не буду рассматривать методику обновления карты высот, которую позаимствовал с веб-сайта Хьюго Элиаса, который, похоже, исчез из Интернета. Он тоже узнал об этом алгоритме от неизвестного автора и реализовал его на C для выполнения в ЦП. Исходный код показанного ниже приложения находится здесь.


Здесь я использовал карту высот только для смещения текстуры и добавления затенения, но в третьем измерении можно реализовать гораздо более интересные приложения. Когда свёрточная текстура интерпретируется вершинным шейдером (vertex shader), плоскую подразделённую плоскость можно искажать для создания трёхмерных волн. К получившейся форме можно применить обычное затенение и освещение.

Стоит заметить, что пиксели в свёрточной текстуре показанного выше примера иногда хранят очень малые значения, которые не должны пропадать вследствие погрешностей округления. Следовательно, цвета каналы этой текстуры должны иметь большее разрешение, а не стандартные 8 бит. В этом примере я увеличил размер каждого цветового канала до 16 бит, что дало достаточно точные результаты. Если вы храните не пиксели, то часто требуется повышение точности текстуры. К счастью, современные графические API поддерживают такую возможность.

Используем все каналы


Рисунок 3: пиксель травы.

В примере с водой используются только красный и зелёный каналы, но в следующем примере мы применим все четыре. Симулируется поле с травой (или с деревьями), которую можно смещать при помощи курсора. На рисунке 3 показано, какие данные хранятся в пикселе. В красном и зелёном каналах хранится смещение, а в синем и в альфе — скорость. Эта скорость обновляется для смещения в сторону положения покоя с постепенно затухающим волновым движением.

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

Исходный код приложения можно найти здесь.


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

Хотя существует множество алгоритмов и шейдеров для создания эффектов ветра и смещения растительности, данный подход имеет серьёзное преимущество: рисование воздействий на свёрточной текстуре — это очень малозатратный процесс. Если эффект применяется в игре, то движение растительности может определяться сотнями различных воздействий. Не только главный герой, но и все объекты, животные и движения могут влиять на мир ценой незначительных затрат.

Другие варианты использования и изъяны


Можно придумать множество других применений техники, например:

  • При помощи свёрточной текстуры можно симулировать скорость ветра. На текстуре можно рисовать препятствия, заставляющие воздух огибать их. Частицы (дождь, снег и листья) могут использовать эту текстуру для облёта препятствий.
  • Можно симулировать распространение дыма или огня.
  • В текстуру можно закодировать толщину слоя снега или песка. Следы и другие взаимодействия со слоем могут создавать вмятины и отпечатки на слое.


При использовании этот метода существуют сложности и ограничения:

  • Сложно подстраивать анимации под меняющуюся частоту кадров. Например, в приложении с падающим песком песчинки падают с постоянной скоростью — один пиксель за обновление. Возможным решением может стать обновление свёрточных текстур с постоянной частотой, аналогично тому, как работает большинство физических движков; физический движок работает с постоянной частотой, и его результаты интерполируются.
  • Передача данных в GPU — быстрый и простой процесс, однако получать данные обратно не так легко. Это означает, что большинство создаваемых данной техникой эффектов однонаправленны; они передаются в GPU, а GPU выполняет свою работу без дальнейшего вмешательства и обратной связи. Если бы я хотел встроить длину волн из примера с водой в физические вычисления (например, чтобы корабли колебались вместе с волнами), то мне бы понадобились значения из свёрточной текстуры. Получение данных текстур из GPU — ужасно медленный процесс, который не нужно выполнять в реальном времени. Решением этой проблемы может быть выполнение двух симуляций: одной с высоким разрешением для графики воды как свёрточной текстуры, другой с низким разрешением в ЦП для физики воды. Если алгоритмы одинаковы, то расхождения могут оказаться вполне приемлемыми.


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

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

Вывод


GPU, особенно в 2D-программах, чаще всего простаивает. Хотя кажется, что его можно использовать только в рендеринге сложных 3D-сцен, продемонстрированная в этой статье техника показывает как минимум один способ других применения мощи GPU. Используя возможности, для которых разрабатывались GPU, можно реализовать интересные эффекты и анимации, которые обычно слишком затратны для ЦП.

© Habrahabr.ru