Системы частиц это одни из самых простых способов сделать 3D сцену визуально богаче. В одном из наших Android приложений 3D Buddha Live Wallpaper достаточно простая сцена, которой было бы неплохо добавить чуть больше деталей. И когда мы думали как добавить разнообразия изображению то самым очевидным решением заполнить пустое пространство вокруг статуи Будды стало добавление клубов дыма или тумана. Благодаря использованию мягких частиц мы достигли довольно хорошего результата. В этой статье мы детально опишем реализацию мягких частиц на чистом WebGL / OpenGL ES без использования сторонних библиотек и готовых 3D-движков.
Разница между старым и обновленным приложением даже превзошла наши ожидания. Простенькие частицы дыма существенно улучшили сцену, сделали ее богаче и полнее. Клубы дыма это и дополнительные детали за которые “цепляется глаз”, а также способ сделать переход между основными объектами и фоном более плавным:
Так что такое эти мягкие частицы? Вы можете помнить что во многих старых играх (времен Quake 3 и CS 1.6) эффекты дыма и взрывов имели очень четкие плоские границы на пересечении частиц с другой геометрией. Все современные игры уже не имеют подобных артефактов благодаря использованию мягких частиц — то есть частиц с размытыми, “мягкими” краями вокруг прилегающих объектов.
Что требуется для того чтобы сделать частицы мягкими? Во-первых, нужна информация о глубине сцены для того чтобы определить места пересечения частиц с другими объектами и смягчить их. Затем нам нужно определить места пересечения геометрий сцены и частицы путем сравнения глубины сцены и частицы во фрагментном шейдере — пересечения там, где глубины одинаковые. Далее мы рассмотрим процесс рендеринга пошагово. Обе реализации сцены под Android OpenGL ES и WebGL одинаковы, основное отличие только в загрузке ресурсов. Реализация на WebGL имеет открытый исходный код и вы можете взять его тут — https://github.com/keaukraine/webgl-buddha.
Для рендеринга карты глубины сцены, сперва нам потребуется создать текстуры для карты глубины и цвета и назначить их определенному FBO. Это делается в методе initOffscreen() в файле BuddhaRenderer.js.
Сам рендеринг Actual объектов сцены в карту глубины выполняется в методе drawDepthObjects(), который отрисовывает статую Будды и плоскость пола. Однако, тут есть одна хитрость для улучшения производительности. Поскольку на данном этапе рендеринга нам не нужна информация о цвете, а лишь глубина, рендеринг в цветовой буфер отключается вызовом gl.colorMask(false, false, false, false), а затем включается снова вызовом gl.colorMask(true, true, true, true). Функция glcolorMask() может выключать и выключать запись красного, зеленого синего и альфа компонента по отдельности, так что для того чтобы полностью отключить запись в буфер цвета мы ставим всем компонентам флажок false, а затем включаем их для рендеринга на экран, выставляя их все в true. Результат рендеринга в текстуру глубины можно увидеть, раскомментировав вызов drawTestDepth() в методе drawScene(). Поскольку текстура карты глубины имеет только один канал, она воспринимается как только красная, синий и зеленый каналы равно нулю. Визуализация карты глубины нашей сцены выглядит так:
Код шейдера, который используется для отрисовки частиц находится в файле SoftDiffuseColoredShader.js. Давайте разберемся как он устроен.
Основная идея поиска пересечения геометрий частицы и сцены состоит в сравнении значения текущей глубины фрагмента с сохраненным значением из карты глубины.
Первый шаг в сравнении глубин это линеаризация значений глубин, поскольку оригинальные значения экспоненциальные. Это делается с помощью функции calc_depth(). Эта техника хорошо описана здесь — https://community.khronos.org/t/soft-blending-do-it-yourself-solved/58190. Для линеаризации значений нам понадобится юниформ-переменная vec2 uCameraRange, компоненты x и y которой содержат значения ближней и дальней плоскостей отсечения камеры. Затем шейдер вычисляет линейную разницу между глубиной частицы и сцены — это значение сохраняется в переменной a. Однако, если мы будем применять это значение к цвету фрагмента, мы получим слишком прозрачные частицы — цвет будет затухать линейно от любой геометрии позади частицы, и затухать довольно быстро. Вот как выглядит визуализация линейной разницы глубины (Вы можете раскомментировать соответствующую строчку кода в шейдере и увидеть ее):
Чтобы частицы были более прозрачными только возле границы пересечения (в районе a=0) мы применим функцию GLSL smoothstep() к значению переменной a со значением перехода от 0 до коэффициента, заданного в юниформе uTransitionSize, который определяет ширину прозрачного перехода. Если Вы желаете узнать больше о работе функции smoothstep() и увидеть пару интересных примеров ее использования, рекомендуем прочитать эту статью — http://www.fundza.com/rman_shaders/smoothstep/. Финальный коэффициент сохраняется в переменной b. Для режима миешивания цветов, используемого в нашей сцене, достаточно просто перемножить цвет частицы взятый из текстуры на этот коэффициент; в других реализациях частиц Вам может понадобиться изменение например только альфа канала. Если в шейдере раскомментировать строчку кода для визуализации этого коэффициента то результат будет выглядеть так:
Сравнение различных значений коэффициента “мягкости” частиц:
В этой сцене мелкие пылинки отрисовываются как точечные спрайты (примитивы типа GL_POINTS). Этот режим удобен тем что он автоматически создает готовую квадратную геометрию частицы с текстурными координатами. Однако, они имеют и недостатки, которые делают их использование неуместным для крупных частиц клубов тумана. Прежде всего, они отсекаются плоскостями отсечения матрицы камеры по координатам центра спрайта. Это приводит к тому что они резко исчезают из вида на краях экрана. Также, квадратная форма спрайта не очень оптимальна для фрагментного шейдера так как он вызывается и в тех местах где текстура частиц пустая, что вызывает заметную излишнюю перерисовку. Мы используем оптимизированную форму частицы — с обрезанными краями в тех местах где текстура полностью прозрачна:
Такие модели для частиц обычно называются billboard. Конечно, они не могут быть отрисованы как примитивы GL_POINTS, поэтому каждая частица рисуется отдельно. Это создает не очень много вызовов drawElements, во всей сцене всего 18 частиц тумана. Они должны быть размещены в произвольных координатах, масштабированы но повернуты таким образом, чтобы всегда быть перпендикулярными к камере независимо от ее положения. Это достигается модификацией матрицы, описанной в этом ответе на StackOverflow. В файле BuddhaRenderer.js есть метод calculateMVPMatrixForSprite() который создает MVP-матрицы для billboard моделей. Он выполняет все обычные трансформации перемещения и масштабирования а затем использует resetMatrixRotations() для сброса компонента вращения матрицы model-view перед тем как она перемножается на матрицу проекции. Результирующая матрица выполняет трансформацию в результате которой модель всегда направлена ровно на камеру.
Финальный результат можно посмотреть вживую здесь.
Можете изучать и переиспользовать для своих проектов исходный код с Github.
Разница между старым и обновленным приложением даже превзошла наши ожидания. Простенькие частицы дыма существенно улучшили сцену, сделали ее богаче и полнее. Клубы дыма это и дополнительные детали за которые “цепляется глаз”, а также способ сделать переход между основными объектами и фоном более плавным:
Мягкие частицы
Так что такое эти мягкие частицы? Вы можете помнить что во многих старых играх (времен Quake 3 и CS 1.6) эффекты дыма и взрывов имели очень четкие плоские границы на пересечении частиц с другой геометрией. Все современные игры уже не имеют подобных артефактов благодаря использованию мягких частиц — то есть частиц с размытыми, “мягкими” краями вокруг прилегающих объектов.
Рендеринг
Что требуется для того чтобы сделать частицы мягкими? Во-первых, нужна информация о глубине сцены для того чтобы определить места пересечения частиц с другими объектами и смягчить их. Затем нам нужно определить места пересечения геометрий сцены и частицы путем сравнения глубины сцены и частицы во фрагментном шейдере — пересечения там, где глубины одинаковые. Далее мы рассмотрим процесс рендеринга пошагово. Обе реализации сцены под Android OpenGL ES и WebGL одинаковы, основное отличие только в загрузке ресурсов. Реализация на WebGL имеет открытый исходный код и вы можете взять его тут — https://github.com/keaukraine/webgl-buddha.
Рендеринг карты глубины
Для рендеринга карты глубины сцены, сперва нам потребуется создать текстуры для карты глубины и цвета и назначить их определенному FBO. Это делается в методе initOffscreen() в файле BuddhaRenderer.js.
Сам рендеринг Actual объектов сцены в карту глубины выполняется в методе drawDepthObjects(), который отрисовывает статую Будды и плоскость пола. Однако, тут есть одна хитрость для улучшения производительности. Поскольку на данном этапе рендеринга нам не нужна информация о цвете, а лишь глубина, рендеринг в цветовой буфер отключается вызовом gl.colorMask(false, false, false, false), а затем включается снова вызовом gl.colorMask(true, true, true, true). Функция glcolorMask() может выключать и выключать запись красного, зеленого синего и альфа компонента по отдельности, так что для того чтобы полностью отключить запись в буфер цвета мы ставим всем компонентам флажок false, а затем включаем их для рендеринга на экран, выставляя их все в true. Результат рендеринга в текстуру глубины можно увидеть, раскомментировав вызов drawTestDepth() в методе drawScene(). Поскольку текстура карты глубины имеет только один канал, она воспринимается как только красная, синий и зеленый каналы равно нулю. Визуализация карты глубины нашей сцены выглядит так:
Рендеринг частиц
Код шейдера, который используется для отрисовки частиц находится в файле SoftDiffuseColoredShader.js. Давайте разберемся как он устроен.
Основная идея поиска пересечения геометрий частицы и сцены состоит в сравнении значения текущей глубины фрагмента с сохраненным значением из карты глубины.
Первый шаг в сравнении глубин это линеаризация значений глубин, поскольку оригинальные значения экспоненциальные. Это делается с помощью функции calc_depth(). Эта техника хорошо описана здесь — https://community.khronos.org/t/soft-blending-do-it-yourself-solved/58190. Для линеаризации значений нам понадобится юниформ-переменная vec2 uCameraRange, компоненты x и y которой содержат значения ближней и дальней плоскостей отсечения камеры. Затем шейдер вычисляет линейную разницу между глубиной частицы и сцены — это значение сохраняется в переменной a. Однако, если мы будем применять это значение к цвету фрагмента, мы получим слишком прозрачные частицы — цвет будет затухать линейно от любой геометрии позади частицы, и затухать довольно быстро. Вот как выглядит визуализация линейной разницы глубины (Вы можете раскомментировать соответствующую строчку кода в шейдере и увидеть ее):
Чтобы частицы были более прозрачными только возле границы пересечения (в районе a=0) мы применим функцию GLSL smoothstep() к значению переменной a со значением перехода от 0 до коэффициента, заданного в юниформе uTransitionSize, который определяет ширину прозрачного перехода. Если Вы желаете узнать больше о работе функции smoothstep() и увидеть пару интересных примеров ее использования, рекомендуем прочитать эту статью — http://www.fundza.com/rman_shaders/smoothstep/. Финальный коэффициент сохраняется в переменной b. Для режима миешивания цветов, используемого в нашей сцене, достаточно просто перемножить цвет частицы взятый из текстуры на этот коэффициент; в других реализациях частиц Вам может понадобиться изменение например только альфа канала. Если в шейдере раскомментировать строчку кода для визуализации этого коэффициента то результат будет выглядеть так:
Сравнение различных значений коэффициента “мягкости” частиц:
Оптимизация отрисовки спрайтов
В этой сцене мелкие пылинки отрисовываются как точечные спрайты (примитивы типа GL_POINTS). Этот режим удобен тем что он автоматически создает готовую квадратную геометрию частицы с текстурными координатами. Однако, они имеют и недостатки, которые делают их использование неуместным для крупных частиц клубов тумана. Прежде всего, они отсекаются плоскостями отсечения матрицы камеры по координатам центра спрайта. Это приводит к тому что они резко исчезают из вида на краях экрана. Также, квадратная форма спрайта не очень оптимальна для фрагментного шейдера так как он вызывается и в тех местах где текстура частиц пустая, что вызывает заметную излишнюю перерисовку. Мы используем оптимизированную форму частицы — с обрезанными краями в тех местах где текстура полностью прозрачна:
Такие модели для частиц обычно называются billboard. Конечно, они не могут быть отрисованы как примитивы GL_POINTS, поэтому каждая частица рисуется отдельно. Это создает не очень много вызовов drawElements, во всей сцене всего 18 частиц тумана. Они должны быть размещены в произвольных координатах, масштабированы но повернуты таким образом, чтобы всегда быть перпендикулярными к камере независимо от ее положения. Это достигается модификацией матрицы, описанной в этом ответе на StackOverflow. В файле BuddhaRenderer.js есть метод calculateMVPMatrixForSprite() который создает MVP-матрицы для billboard моделей. Он выполняет все обычные трансформации перемещения и масштабирования а затем использует resetMatrixRotations() для сброса компонента вращения матрицы model-view перед тем как она перемножается на матрицу проекции. Результирующая матрица выполняет трансформацию в результате которой модель всегда направлена ровно на камеру.
Результат
Финальный результат можно посмотреть вживую здесь.
Можете изучать и переиспользовать для своих проектов исходный код с Github.