Эта статья является второй частью нашей серии о создании H – Immersion. Первую часть можно прочитать здесь: Погружение в Immersion.

При создании анимации всего лишь в 64 КБ сложно использовать готовые изображения. Мы не можем хранить их традиционным способом, потому что это недостаточно эффективно, даже если применять сжатие, например JPEG. Альтернативное решение заключается в процедурной генерации, то есть в написании кода, описывающего создание изображений во время выполнения программы. Нашей реализацией такого решения стал генератор текстур — фундаментальная часть нашего тулчейна. В этом посте мы расскажем, как разрабатывали и использовали его в H – Immersion.


Прожекторы субмарины освещают детали морского дна.

Ранняя версия


Генерация текстур стала одним из самых первых элементов нашей кодовой базы: в нашем первом интро B – Incubation уже использовались процедурные текстуры. Код состоял из набора функций, выполняющих заливку, фильтрацию, преобразования и комбинирование текстур, а также из одного большого цикла, обходящего все текстуры. Эти функции были написаны на чистом C++, но позже был добавлено взаимодействие C API, чтобы их можно было вычислять интерпретаторе C PicoC. В то время мы использовали PicoC для того, чтобы снизить время, занимаемое каждой итерацией: так нам удавалось изменять и перезагружать текстуры в процессе выполнения программы. Переход на подмножество C был небольшой жертвой по сравнению с тем, что теперь мы могли менять код и видеть результат сразу, не заморачиваясь закрытием, перекомпиляцией и повторной загрузкой всего демо.


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


В этой сцене из F – Felix’s workshop были использованы различные текстуры древесины.

Какое-то время мы исследовали возможности этого генератора, и в результате выложили его на веб-сервер с небольшим скриптом на PHP и простым веб-интерфейсом. Мы могли писать код текстур в текстовом поле, а скрипт передавал его в генератор, который затем сбрасывал результат как файл PNG для отображения на странице. Очень скоро мы начали делать наброски прямо на работе во время обеденного перерыва и делиться своими небольшими шедеврами с другими членами группы. Такое взаимодействие сильно мотивировало нас к творческому процессу.


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

Полный редизайн


Долгое время генератор текстур почти не менялся; мы считали, что он хорош, и наша эффективность перестала повышаться. Но однажды мы обнаружили, что на Интернет-форумах есть множество художников, демонстрирующих свои полностью процедурно сгенерированные текстуры, а также устраивающих челленджи на разные темы. Процедурный контент когда-то был «фишкой» демо-сцены, но Allegorithmic, ShaderToy и подобные им инструменты сделали его доступным для широкой публики. Мы не обращали на это внимания, и они начали с лёгкостью класть нас на лопатки. Неприемлемо!


Fabric Couch. Полностью процедурная текстура ткани, созданная в Substance Designer. Автор: Imanol Delgado. www.artstation.com/imanoldelgado

image

Forest Floor. Полностью процедурная текстура лесной почвы, созданная в Substance Designer. Автор: Daniel Thiger. www.artstation.com/dete

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

Самой важной архитектурной ошибкой была реализация генерирования как множества операций с объектами текстур. С точки зрения высокоуровневой перспективы это может быть и правильный подход, но с точки зрения реализации такие функции как texture.DoSomething() или Combine(textureA, textureB) имеют серьёзные недостатки.

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

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

Новая структура решает эти проблемы благодаря реорганизации логики. Большинство функций на практике независимо выполняет одну и ту же операцию для каждого элемента текстуры. Поэтому вместо написания функции texture.DoSomething(), обходящей все элементы, мы можем написать texture.ApplyFunction(f), где f(element) работает только для отдельного элемента текстуры. Затем f(element) можно написать в соответствии с конкретной текстурой.

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

Было:


// Логика находится на уровне текстур.
// API раздут.
// Всё что есть - это API.
// Генерирование текстуры проходит за множество проходов.
class ProceduralTexture {
  void DoSomething(parameters) {
    for (int i = 0; i < size; ++i) {
      // Здесь подробности реализации.
      (*this)[i] = …
    }
  }
  void PerlinNoise(parameters) { … }
  void Voronoi(parameters) { … }
  void Filter(parameters) { … }
  void GenerateNormalMap() { … }
};

void GenerateSomeTexture(texture t) {
  t.PerlinNoise(someParameter);
  t.Filter(someOtherParameter);
  … // и т.д.
  t.GenerateNormalMap();
}

Стало:


// Логика обычно находится на уровне элементов текстур.
// API минимален.
// Операции пишутся по мере необходимости.
// Количество проходов при генерировании текстур снижено.
class ProceduralTexture {
  void ApplyFunction(functionPointer f) {
    for (int i = 0; i < size; ++i) {
      // Реализация передаётся как параметр.
      (*this)[i] = f((*this)[i]);
    }
  }
};

void GenerateNormalMap(ProceduralTexture t) { … }

void SomeTextureGenerationPass(void* out, PixelInfo in) {
  result = PerlinNoise(in);
  result = Filter(result);
  … // и т.д.
  *out = result;
}

void GenerateSomeTexture(texture t) {
  t.ApplyFunction(SomeTextureGenerationPass);
  GenerateNormalMap(t);
}

Параллелизация


Для генерации текстур требуется время, и очевидный кандидат для снижения этого времени — параллельное выполнение кода. По крайней мере, можно научиться генерировать несколько текстур одновременно. Именно так мы сделали для F Felix’s workshop, и это очень снизило время загрузки.

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




Иллюстрация идеи, исследованной и отброшенной нами для H – Immersion: мозаичное украшение с облицовкой из орихалка. Здесь она показана в нашем интерактивном инструменте для редактирования.

Генерирование на стороне GPU


Если это всё ещё не очевидно, то скажу, что генерирование текстур полностью выполняется в ЦП. Возможно, кто-то из вас сейчас читает эти строки и недоумевает «но почему?!». Кажется, что очевидным шагом является генерация текстур в видеопроцессоре. Для начала он на порядок увеличит скорость генерации. Так почему же мы его не используем?

Основная причина в том, что цель нашего небольшого редизайна заключалась в том, чтобы остаться на CPU. Переход на GPU означал бы гораздо больше работы. Нам бы пришлось решать дополнительные проблемы, для которых у нас пока недостаточно опыта. Работая с CPU, мы имеем чёткое понимание того, что хотим, и знаем, как исправить предыдущие ошибки.

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

Генерирование текстур и физически точный шейдинг


Ещё одно ограничение старого дизайна заключалось в том, что текстура рассматривалась только как RGB-изображение. Если нам нужно было сгенерировать больше информации, допустим diffuse-текстуру и текстуру нормалей для той же поверхности, то ничего нам не мешало это сделать, но API особо и не помогал. Особенно важно это стало в контексте физически точного шейдинга (Physically Based Shading, PBR).

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

В PBR-конвейере поверхности обычно используют наборы из нескольких текстур, представляющих физические значения, а не требуемый художественный результат. Диффузная цветовая текстура, которая ближе всего к тому, что часто называют «цветом» поверхности, обычно плоская и неинтересная. Цвет specular определяется коэффициентом преломления поверхности. БОльшая часть деталей и вариативности берётся из текстур нормалей и roughness (шероховатости) (которые кто-то может считать одним и тем же, но с двумя разными масштабами). Воспринимаемая отражающая способность поверхности становится следствием из уровня её roughness. На этом этапе логичнее будет думать с точки зрения не текстур, а материалов.










Новая структура позволяет нам объявлять для текстур произвольные форматы пикселей. Сделав её частью API, мы позволяем ему заниматься всем boilerplate-кодом. После объявления формата пикселей мы можем сосредоточиться на творческом коде, не тратя лишних усилий на обработку этих данных. Во время выполнения он сгенерирует несколько текстур и прозрачным образом передаст их в GPU.

В некоторых PBR-конвейерах цвета diffuse и specular не передаются непосредственно. Вместо них используются параметры «base color» и «metalness», что имеет свои достоинства и недостатки. В H – Immersion мы используем модель diffuse+specular, а материал обычно состоит из пяти слоёв:

  1. Цвет Diffuse (RGB; 0: Vantablack; 1: fresh snow).
  2. Цвет Specular (RGB: доля отражённого под 90° света, также известная как F0 или R0).
  3. Roughness (A; 0: идеально гладкий; 1: похожий на резину).
  4. Нормали (XYZ; единичный вектор).
  5. Подъём рельефа (A; используется для parallax occlusion mapping).

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



На изображениях выше показан недавний эксперимент с генерацией локального ambient occlusion на основании высоты. Для каждого направления мы проходим заданное расстояние и сохраняем наибольший наклон (разность высот, поделенная на расстояние). Затем мы вычисляем occlusion из среднего наклона.

Ограничения и работа на будущее


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

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

Кроме того, в этой статье, несмотря на использованное слово «материалы», мы говорили только о текстурах, но не о шейдерах. Однако применение материалов должно приводить и к шейдерам. Такое противоречие отражает ограничения существующей структуры: генерирование текстур и шейдинг — это две отдельные части, разделённые мостом. Мы пытались сделать так, чтобы этот мост пересечь было как можно проще, но на самом деле хотим, чтобы эти части стали единым. Например, если у материала есть и статические, и динамические параметры, то мы хотим описывать их в одном месте. Это сложная тема и мы пока не знаем, найдётся ли хорошее решение, но давайте не будем забегать вперёд.

image

Эксперимент по созданию текстуры ткани, похожей на показанную выше работу Imadol Delgado.

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


  1. jimmyjonezz
    03.08.2018 15:48

    Раньше часто следил за сценой. Поражался работами Фарбрауш, особенно после их релиза 96 К игры .kkrieger.


  1. AVI-crak
    04.08.2018 11:29

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


    1. lieff
      04.08.2018 11:33

      Ссылка на гитхаб же есть github.com/laurentlb/Ctrl-Alt-Test/tree/master/F/src/texgen