Как оживить картинку в браузере. Многопроходный рендеринг в WebGL
Каждый, кто сталкивался с трехмерной графикой, рано или поздно открывал документацию на методы отрисовки, которые предполагают несколько проходов рендерера. Такие методы позволяют дополнить картинку красивыми эффектами, вроде свечения ярких пятен (Glow), Ambient occlusion, эффекта глубины резкости.
И «взрослый» OpenGL, и мой любимый WebGL предлагают богатую функциональность для отрисовки результатов в промежуточные текстуры. Однако управление этой функциональностью — довольно сложный процесс, в котором очень легко получить ошибку на любом из этапов, начиная от создания текстур нужного разрешения до именования юниформ и передачи их в соответствующий шейдер.
Чтобы разобраться, как правильно готовить WebGL, мы обратились к специалистам компании Align Technology. Они решили создать специальный менеджер для управления всем этим зоопарком из разных текстур, которым было бы удобно пользоваться. Что из этого получилось — будет под катом. Важно, что неподготовленного читателя, который никогда до этого не сталкивался с необходимостью организации многопроходного рендеринга, статья может показаться непонятной. Задача довольно специфическая, но и безумно интересная.
Чтобы вы понимали всю серьезность сиутации, коротко расскажу о компании. В Align выпускают продукт, который позволяет людям исправлять улыбки без традиционных брекетов. То есть их непосредственные потребители — это доктора. Это довольно ограниченная аудитория со специфическими запросами, накладывающими фантастические требования на надежность, производительность и качество пользовательского интерфейса. В свое время основным инструментом был выбран С++, но у него было серьезное ограничение: только десктопное приложение, только для Windows. Примерно два года назад начался переход на веб-версию. Возможности современных браузеров и стека технологий позволили быстро и удобно пересоздавать пользовательский интерфейс и адаптировать кодовую базу, которая до этого писалась почти 15 лет. Конечно, это привело к необходимости решать кучу задач на фронте и бэкенде, в том числе — к необходимости оптимизировать объемы данных и скорости загрузки. Этим задачам будут посвящены эта и следующие статьи.
И дабы дважды не вставать, я постараюсь не загромождать пост исходниками. То есть все, что входит в детали реализации и навевает тоску читателям кода, будет по возможности поскипано или сокращено до чистой, незамутненной идеи. Повествование будет вестись от первого лица, как это рассказывал Василий Ставенко — один из специалистов Align Technology, который согласился приоткрыть нам завесу тайны внутренней кухни WebGL-фронта.
Описание проблемы
Для начала стоило бы рассказать, что, собственно, мы хотели реализовать и что для этого требовалось. Наша специфика не подразумевает большое количество визуальных эффектов. Мы решили реализовать Screen Space Ambient Occlusion (или SSAO) и простенькую тень.
SSAO — это, грубо говоря, подсчет суммарного затенения в точке, окруженной другими точками. Вот суть этой идеи:
Функция textureLookup выбирает из подключенной текстуры пиксель, представляющий собой не цвет, а позицию точки. Далее вычисляем его освещенность как отношение его глубины к удалению от текущего, рисуемого фрагмента в координатах gl_FragCoords . Потом мы делаем особу магию с волшебными числами, чтобы получить значение в нужном диапазоне.
Получившаяся текстура будет иметь примерно вот такой вид:
Вот так выглядит окончательный результат:
Заметно, что SSAO-текстура имеет меньшее разрешение, чем полное изображение. Это сделано нарочно. Сразу после отрисовки позиций в фрагменты мы ужимаем текстуру, и только после этого вычисляем SSAO. Меньшее разрешение означает более быструю отрисовку и процессинг. А значит, что перед тем, как мы будет компоновать конечное изображение, нам надо увеличить разрешение промежуточного изображения.
Резюмируя, нам необходимо отрисовывать следующие текстуры:
- Текстура позиций оригинального разрешения в формате GL_FLOAT
- Текстура позиций малого разрешения.
- Текстура SSAO малого разрешения.
- Размытая текстура SSAO малого разрешения.
- Размытая текстура SSAO высокого разрешения.
- Текстура маски тени.
- Изображение сцены, отрисованное с правильными материалами.
Зависимость и переиспользование
Большая часть текстур может быть отрисована, только если уже есть какие-то отрисованные текстуры. Причем некоторые из них могут быть использованы несколько раз. То есть необходим механизм, работающий с зависимостями.
Отладка
Для отладки процесса рендеринга может быть полезно вывести любую из текстур в имеющийся контекст.
Управление текстурами и фреймбуферами
Поскольку для нашей работы мы уже используем фреймворк THREE.js, следующие требования уже вытекают из взаимодействия с ним. Мы решили не скатываться в чистый WebGL и задействовали THREE.WebGLRenderTarget , который, к сожалению, дает оверхед фреймбуферов, связывая воедино текстуру и созданный объект фреймбуфера, не позволяя использовать имеющиеся буфера для других текстур. Но даже с этим оверхедом наш рендеринг работает на приемлемых скоростях, а управление таким объектом намного легче, чем управление двумя связанными, но при этом независимыми объектами.
Управление разрешением текстур
Нам бы очень хотелось иметь возможность «играться» с параметрами даунсэмплинга, с магией чисел и пределов освещенности и не заморачиваться тем, что надо полностью менять код вывода изображения — менять его разрешения, матрицы и прочие вещи. Поэтому было решено «зашить» механизм сэмплинга в наш менеджер.
Замена материала перед рендерингом сцены
Материал всех объектов в THREE.Scene должен быть заменен для отрисовки позиций, с учетом видимости объектов, а потом восстановлен без потерь. Тут следует отметить, что можно было бы воспользоваться параметром Scene.overrideMaterial . Но в нашем случае логика оказалась несколько более сложной.
Реализация — основная идея
Что же мы сделали в итоге? Во-первых, сделали менеджер, описание которого вы найдете ниже. И написали такие классы, которые автоматически читают шейдеры и смотрят, какие текстуры им нужны для отрисовки самих себя. Менеджер должен уметь понять, что есть зависимости для отрисовки текстуры, и должен отрисовать необходимые зависимости.
Этот менеджер должен был, по задумке, быть проинициализирован экземплярами класса Pass. То есть понадобится еще один объект, который добавит в него проходы и будет уже application-specific. Из-за того, что в современных WebGL-шейдерах мы не можем задать имя исходящей текстуры, нам пришлось сделать ScreenSpacePass безымянным и давать ему имя при добавлении. А могли бы вычитывать его из текста шейдера.
Вот такой вот метод:
Да, на этот же менеджер мы повесили и управление состоянием screenSpaceScene. К счастью, это один единственный меш с геометрией, чтобы закрыть весь экран.
Вот такой вот метод нам понадобился для отрисовки конкретного прохода на экран:
- Каждая цель — это наш Pass для отрисовки.
- this.passes — это экземпляр javascript Map() (Тип: Map<String, Pass> ).
- target.dependencies — это список текстурных юниформ в шейдере. Их мы считываем из исходника шейдера с помощью регулярных выражений.
- installDependencies — это ничто иное, как установка юниформ.
- prepareDependencies для каждой зависимости запускает функцию this.prerender , которая является младшей сестрой указанной функции. Разница методов небольшая, например, отрисовка идет в фреймбуфер цели:
Таким образом, у нас нарисовался общий класс для наших проходов с вот таким вот интерфейсом:
Как это должно работать
Для начала надо настроить наш менеджер. Для этого мы его инстанциируем и добавляем в него некоторое количество Pass-ов. Затем, когда нам надо нарисовать какой-то Pass на наш контекст, мы просто вызываем
Этот Pass должен отрисоваться на экране, а менеджер должен подготовить перед этим все зависимости. Поскольку мы хотим переиспользовать текстуры, наш менеджер будет проверять наличие уже отрисованных текстур, и чтобы он не решил, что текстуры с прошлого кадра — это те текстуры, которые можно не рисовать, мы перед началом отрисовки сбрасываем старые текстуры. Для этого у менеджера есть функция с соответствующим названием start .
Сумятицу в стройную схему внесла потребность рисовать на основной канвас полупрозрачных текстур. При блендинге не нужно стирать предыдущие результаты, да и сам блендинг надо настроить. В нашем случае подготовленные текстуры накладываются на изображение при конечной отрисовке именно блендингом. Процедура такая:
- Стираем фон с помощью gl.Clear — three.js делает это автоматически, если ему не сказать, что стирать не надо.
- Накладываем тень с помощью блендинга.
- Накладываем изображение нашей челюсти, используя прозрачность.
- Накладываем SSAO.
Видно, что небольшое отличие состоит в том, что буфер цвета не стирается, а все остальные буферы очищаются.
Если нам захочется вывести на экран какую-то промежуточную текстуру (например, в целях отладки), мы можем лишь слегка модифицировать render. Например, текстура с SSAO, которуя я привел выше, была отрисована вот таким кодом:
Реализация ScenePass
Теперь подробней остановимся на том, как именно рисовать наши проходы сцен в текстуры. Очевидно, что нам потребуется что-то, что умеет отрисовывать сцену, заменяя материал, и что-то, что будет отрисовывать все в экранных координатах.
Это весь класс. Получился довольно простым, поскольку почти всю функциональность удалось оставить в родителе. Как видите, я решил оставить overrideMaterial на тот возможный случай, когда мы сможем заменить материал на всей сцене разом в одну операцию присваивания, а не во время последовательной замены материала на всех подходящих объектах. Собственно, _prerender и _postrender — это и есть довольно умные заменители материала для каждого отдельного меша. Вот как они выглядят в нашем случае:
Scene.traverse — это метод THREE.js, который рекурсивно проходит по всей сцене.
Реализация ScreenSpacePass
ScreenSpacePass был задуман так, чтобы вытащить из шейдера максимум необходимой информации для того, чтобы работать с ним без лишнего бойлерплейта. Класс получился довольно сложным. Основная сложность пришлась на логику, которая обеспечивает сэмплирование, — то есть на установку правильных разрешений в текстуре. Пришлось заводить дополнительный метод, чтобы задавать разрешение текущего фреймбуфера в тех случаях, когда мы хотим отрисовать на экран, а не в текстуру. Пришлось пойти на этот компромисс между технической сложностью, ответственностью классов, количеством сущностей и временем, выделенным на задачу.
Автоматический поиск и установка юниформ помогли быстро найти такие проблемы, как опечатки в именах текстурных юниформ. В таких случаях GL может взять какую-то другую текстуру, и то, что получается у вас на экране, выглядит совершенно не так, как должно, и при этом у вас нет идей, почему.
Здесь исходник получился довольно большим, а класс — довольно умным. Впрочем видно, что большая часть кода как раз выясняет, есть ли в шейдере текстурные юниформы, и устанавливает их в качестве зависимостей.
Ну и в самом конце, я покажу, как мы этим пользовались. Application specific-сущность мы назвали EffectComposer . В его конструкторе создаем описанный менеджер и создаем ему пассы:
В качестве примера — содержимое файла passingFragmentShader.glsl:
Шейдер очень короткий — достать пиксель, который проинтерполируется, и тут же отдать его. Всю работу сделает линейная интерполяция в настройках текстуры ( GL_LINEAR ).
Теперь посмотрим, как будет отрисована positions .
Рабочая сцена нам нужна в других местах программы, поэтому EffectComposer не является ее владельцем, ему ее задают, когда надо.
Как видно, если кто-то сообщил нам об изменении сцены, EffectComposer создаст два Pass-a: один с настройками по умолчанию, а другой — с хитрой заменой материалов. Проходы сцены у нас не содержат каких-то хитрых зависимостей, они, как правило, рисуются сами по себе, однако описываемый подход позволяет это делать, если мы добавим в ScenePass несколько методов для того, чтобы добавлять зависимости. Потому что это неочевидно, какой именно материал из сцены захочет иметь отрисованную зависимость.
Заключение
Несмотря на простоту использования в нашем случае, нам не удалось добиться полностью автоматической генерации пассов, основанных на шейдерах. Мне не хотелось добавлять в шейдеры маркеров, которые бы дополняли проходы прорисовки сцены дополнительными параметрами, такими как параметры вывода текстуры — GL_RGB , GL_RGBA , GL_FLOAT , GL_UNSIGNED_BYTE . Это бы, с одной стороны, упростило код, но дало бы меньше свободы переиспользования шейдеров. То есть эту настройку все равно пришлось описывать.
Стоит упомянуть, что мне пришлось еще реализовать маппинг зависимостей. Это оказалось полезно, если один шейдер мы хотим использовать в нескольких пассах и с разными входящими текстурами. В таком случае каждый пасс стал больше походить на функцию, поэтому у меня появилась идея, как это сделать немного «функциональнее».
Тем не менее, вся разработка оказалась весьма полезной. В частности, она позволяет без существенных сложностей добавлять нам любые эффекты в наш проект. Хотя мне лично больше всего нравится возможность легкого дебага изображений.