В этой статье я представлю свою попытку обобщения вычислений каустики в реальном времени с помощью WebGL и ThreeJS. Тот факт, что это попытка, важен, ведь найти решение, работающее во всех случаях и обеспечивающее 60fps — сложная, если не невозможная задача. Но вы увидите, что при помощи моей методики можно достичь достаточно приличных результатов.

Что такое каустика?


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

Из-за того, что отражение и преломление происходят на волнах воды, вода действует здесь как динамическая линза, создающая эти световые узоры.


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

Чтобы добиться стабильных 60fps, нам нужно вычислять её на графической карте (GPU), поэтому мы будем вычислять каустику только шейдерами, написанными на GLSL.

Для её вычисления нам потребуется:

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


Хорошо известное демо воды на WebGL


Меня всегда поражало это демо Эвана Уоллеса, демонстрирующее визуально реалистичную каустику воды на WebGL: madebyevan.com/webgl-water


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

Он поместил под воду сферу потому, что вычисление пересечения между преломлённым лучом света и сферой — это лёгкая задача, в которой используется очень простая математика.

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


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

Работа с ограничениями GLSL


В написанных на GLSL (OpenGL Shading Language) шейдерах мы можем иметь доступ только к ограниченному объёму информации о сцене, например:

  • Атрибуты текущей отрисовываемой вершины (позиция: 3D-вектор, нормаль: 3D-вектор и т.п.). Мы можем передавать свои атрибуты GPU, но они должны иметь встроенный тип GLSL.
  • Uniform, то есть константы для всего текущего отрисовываемого меша в текущем кадре. Это могут быть текстуры, матрица проецирования камеры, направление освещения и т.п. Они должны иметь встроенный тип: int, float, sampler2D для текстур, vec2, vec3, vec4, mat3, mat4.

Однако нет возможности получать доступ к мешам, присутствующим в сцене.

Именно поэтому демо webgl-water можно сделать только с простой 3D-сценой. Проще вычислять пересечение преломлённого луча и очень простой фигуры, которую можно представить с помощью uniform. В случае сферы её можно задать позицией (3D-вектор) и радиусом (float), поэтому эту информацию можно передавать шейдерам с помощью uniform, а для вычисления пересечений требуется очень простая математика, легко и быстро выполняемая в шейдере.

Некоторые выполняемые в шейдерах методики трассировки лучей передают меши в текстурах, но в 2020 году при рендеринге реального времени на WebGL такое решение неприменимо. Нужно помнить, что для получения достойного результата мы должны вычислять 60 изображений в секунду с большим количеством лучей. Если мы вычисляем каустику, используя 256x256=65536 лучей, то каждую секунду нам придётся выполнять значительное количество вычислений пересечений (которое также зависит от количества мешей в сцене).

Нам нужно найти способ представить подводное окружение в виде uniform и вычислить пересечение, сохраняя при этом достаточную скорость.

Создание карты окружений


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

Shadow mapping — это техника, выполняемая в два прохода:

  • Сначала 3D-сцена рендерится с точки зрения источника освещения. Эта текстура содержит не цвета фрагментов, а глубину фрагментов (расстояние между источником освещения и фрагментом). Эта текстура называется картой теней (shadow map).
  • Затем карта теней используется при рендеринге 3D-сцены. При отрисовке фрагмента на экране мы знаем, есть ли другой фрагмент между источником освещения и текущим фрагментом. Если это так, то мы знаем, что текущей фрагмент находится в тени, и нужно отрисовывать его чуть темнее.

Подробнее о shadow mapping можно прочитать в этом превосходном туториале по OpenGL: www.opengl-tutorial.org/intermediate-tutorials/tutorial-16-shadow-mapping.

Также можно посмотреть интерактивный пример на ThreeJS (нажмите T, чтобы отобразить в левом нижнем углу карту теней): threejs.org/examples/?q=shadowm#webgl_shadowmap.

В большинстве случаев эта методика работает хорошо. Она может работать с любыми неструктурированными мешами в сцене.

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

Вот результат создания карты окружения:


Env map: в каналах RGB хранится позиция по XYZ, в альфа-канале — глубина

Как вычислить пересечение луча и окружения


Теперь, когда у меня есть карта подводного окружения, нужно вычислить пересечение между преломлёнными лучами и окружением.

Алгоритм работает следующим образом:

  • Этап 1: начинаем с точки пересечения между лучом света и поверхностью воды
  • Этап 2: вычисляем преломление с помощью функции refract
  • Этап 3: переходим от текущей позиции в направлении преломлённого луча по одному пикселю в текстуре карты окружения.
  • Этап 4: сравниваем зарегистрированную глубину окружения (хранящуюся в текущем пикселе текстуры окружения) с текущей глубиной. Если глубина окружения больше, чем текущая глубина, то нам нужно двигаться дальше, поэтому мы снова применяем этап 3. Если глубина окружения меньше текущей глубины, то это значит, что луч столкнулся с окружением в позиции, считанной из текстуры окружения и мы нашли пересечение с окружением.


Текущая глубина меньше, чем глубина окружения: нужно двигаться дальше


Текущая глубина больше, чем глубина окружения: мы нашли пересечение

Текстура каустики


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


Текстура яркости каустики (обратите внимание, что эффект каустики менее важен на акуле, потому что она ближе к поверхности воды, что снижает сходимость лучей освещения)

Эта текстура содержит информацию о яркости освещения для каждой точки в 3D-пространстве. При рендеринге готовой сцены мы можем считывать эту яркость освещения из текстуры каустики и получить следующий результат:



Реализацию этой методики можно найти в репозитории Github: github.com/martinRenou/threejs-caustics. Поставьте ей звёздочку, если вам понравилось!

Если вы хотите посмотреть на результаты вычисления каустики, то можете запустить демо: martinrenou.github.io/threejs-caustics.

Об этом алгоритме пересечения


Это решение сильно зависит от разрешения текстуры окружения. Чем больше текстура, тем лучше точность алгоритма, но тем и дольше выполняется поиск решения (прежде чем найти его, нужно считать и сравнить большее количество пикселей).

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

Более того, в WebGL запрещены циклы while (и на то есть веская причина), поэтому нам нужно реализовать алгоритм в цикле for, который может быть развёрнут компилятором. Это означает, что нам требуется условие завершения цикла, известное во время компиляции, обычно это значение «максимальной итерации», которое заставляет нас прекратить поиск решения, если мы не нашли его в течение максимального количества попыток. Такое ограничение приводит к неверным результатам каустики, если преломление оказывается слишком важным.

Наша методика не так быстра, как упрощённый способ, предолженный Эваном Уоллесом, однако она гораздо более гибкая, чем подход с полномасштабной трассировкой лучей, а также может использоваться для рендеринга в реальном времени. Однако скорость по-прежнему зависит от некоторых условий — направления света, яркости преломлений и разрешения текстуры окружения.

Завершаем обзор демо


В этой статье мы рассмотрели вычисление каустики воды, но в демо использовались и другие техники.

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

У нас есть ещё идеи по дальнейшему улучшению методики, в том числе:

  • Хроматические аберрации на каустике: сейчас мы применяем хроматические аберрации к поверхности воды, но этот эффект также должен быть видим на подводной каустике.
  • Рассеивание света в объёме воды.
  • Как посоветовали Мартин Жерар и Алан Волф в Twitter, мы можем повысить производительность с помощью иерархических карт окружения (которые будут использоваться как деревья квадрантов для поиска пересечений). Также они посоветовали рендерить карты окружения с точки зрения преломлённых лучей (предполагая, что они совершенно плоские), благодаря чему производительность станет независимой от угла падения освещения.

Благодарности


Эта работа по реалистичной визуализации воды в реальном времени была проведена в QuantStack и финансировалась ERDC.