Pull to refresh

Рисуем эффект «Таноса» на Android (и не только)

Level of difficultyMedium
Reading time32 min
Views8.2K

Привет! Вопрос мобильным разработчикам: часто ли вам приходится работать с необычным UI? Если вы ответили утвердительно, то я по-доброму вам завидую. В своей повседневной практике мне в основном приходится работать со стандартным набором компонентов и их базовой настройкой. Абсолютно ничего не имею против, но хочется чего-то «эдакого»: кастомных компонентов, написанных с нуля, необычных анимаций и эффектов. Часто подобные вещи вызывают много споров (как среди разработчиков, так и конечных пользователей) а-ля «А на кой оно вообще надо», но лично для меня это ни что иное, как творчество. Кто-то красиво рисует, кто-то красиво поёт, а кто-то пишет красивые уникальные приложения, которыми интересно и приятно пользоваться. И мы, пожалуй, не можем обвинять авторов за бессмысленность «украшательств», как по-хорошему не можем судить художника за его работу.

К чему я – спросите вы. Я отвечу: настраиваю на нужный лад :) В рамках этой статьи мы коснёмся полезной темы и создадим что-то бесполезное в практическом смысле, но несомненно интересное и достаточно уникальное.

Предыстория

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

Эффект доступен на Android и iOS, вживую смотрится ещё интереснее.
Эффект доступен на Android и iOS, вживую смотрится ещё интереснее.

Довольно диковинная штука для мобильных приложений. А вот в играх, к примеру, подобное встречается повсеместно: взрывы, пыль и даже пользовательский интерфейс. За примером далеко идти не нужно: буквально недавно проходил Ghost of Tsushima, где на частицы (лепестки) рассыпался баннер в начале и в конце каждой миссии. Я не смог сдержать своё любопытство и решил разобраться, как это работает, и, главное, попробовать реализовать что-то подобное. Вот мы здесь.

Ghost of Tsushima.
Ghost of Tsushima.

Статья рассчитана по большей части на условного меня из прошлого, который не имеет опыта в области компьютерной графики и ищет начальный путеводитель, поэтому я буду описывать, возможно, совсем банальные вещи. На старте нам понадобятся лишь базовые знания Android для понимания контекста, хотя, к слову, работать мы будем с не совсем актуальными технологиями. На то будут свои причины, о которых речь пойдёт ниже. Как пример, большая часть кода в статье будет представлена на Java, поскольку в конце мы соберем Telegram-клиент (у которого вся кодовая база на Java) с нашим решением и сравним его с официальной версией. По той же причине мы будем работать со старыми-добрыми Views, хотя без особых усилий полученное решение можно адаптировать под Compose. Поехали!

Инструментарий

Зачастую, когда речь идёт о каких-то необычных элементах / эффектах, в голову сразу приходит создание своей Custom View. В нашем случае нам придётся работать с большим количеством частиц и операций над ними: в каждый кадр (в случае с View в вызов метода onDraw()) мы должны посчитать положение для всех частиц. Это очень дорого для последовательной обработки: для картинки размером 128x128 мы уже имеем 16384-е частицы и, соответственно, итерации. Мы могли бы решить проблему путём увеличения размера частиц, однако наша цель – получить настолько малые частицы, насколько позволяет производительность устройства. Идеал, к которому стремимся, – 1 пиксель. Поскольку метод onDraw() вызывается на CPU, мы не можем себе позволить обрабатывать большой массив данных здесь, а значит View здесь не помощник.

Но ведь в играх же всё рассыпается, и работает это на 30, а то и 60 кадрах. Почему? Здесь на помощь приходит GPU. Как это работает? Я не осмелюсь лезть в технические подробности, ведь сам буквально недавно узнал о базовом устройстве GPU, однако опишу принцип работы на понятном примере. Представьте, что CPU – это руководитель отдела, а GPU – это весь офис с сотней сотрудников. Пусть стоит задача – распечатать 100 бланков заявлений. Несомненно, начальник отдела может самостоятельно распечатать 100 бланков, но более эффективно будет дать по листу каждому сотруднику, и они распечатают нужное число бланков одновременно. Более того, сотрудники чаще печатают на принтере и лучше знают, как это делать, а значит, один сотрудник может распечатать бланк чуть быстрее, чем руководитель отдела. Возвращаясь к процессорам, подытожим:

  • CPU имеет широкий спектр задач, которые может выполнять последовательно или распараллеливать между немногочисленными ядрами (значение колеблется от 1 до 10+, но на порядок меньше, чем у GPU).

  • GPU имеет множество маленьких вычислительных ядер (исчисляются сотнями), которые могут выполнять одни и те же инструкции над различными данными одновременно.

Архитектура CPU и GPU.
Архитектура CPU и GPU.

Множество однотипных операций параллельно – это именно то, что нужно, чтобы работать с большим количеством частиц. Но как сказать GPU, что нужно делать? Давайте знакомиться с OpenGL!

OpenGL – это API, использующийся для взаимодействия с GPU и отрисовки 2D / 3D графики. Спецификация OpenGL точно описывает ряд требований к списку функций и тому, как они должны работать. Задача производителей GPU состоит в том, чтобы реализовать данные требования максимально эффективным образом. Как итог, мы можем одинаково работать и получать один результат независимо от производителя и платформы. К слову, для мобильных устройств существует отдельная версия – OpenGL for Embedded Systems (OpenGL ES), которая оптимизирована для работы на устройствах с ограниченными ресурсами.

Стоит отметить, что сегодня, несмотря на по-прежнему широкое применение, OpenGL считается устаревшей технологией. Так, например, на Android рекомендуется использовать Vulkan – более эффективный современный наследник OpenGL. Однако есть причины, почему мы будем использовать именно OpenGL:

  • В первую очередь OpenGL более прост в использовании на Android, в отличие от Vulkan. Android имеет готовый инструментарий для работы с OpenGL, в то время как для Vulkan требуются дополнительные надстройки. В целом OpenGL предоставляет более высокий уровень абстракции и требует меньше кода для реализации одних и тех же вещей, если брать в сравнение с Vulkan.

  • Поддержка Vulkan впервые была добавлена в Android 7.0. Стоит отметить, что многие современные приложения имеют 7 версию в качестве минимально поддерживаемой, однако у Telegram, на который мы ориентируемся, минимальной версией, на которой доступен эффект, является 5.0. Здесь нам доступен OpenGL ES 3.1, но в качестве эксперимента мы пойдём ещё дальше: используем OpenGL ES 2.0, который доступен аж с Android 2.2. В качестве бонуса мы получаем теоретическую поддержку супер слабых устройств, которые не имеют поддержки OpenGL ES 3.0 (хотя процент таких устройств крайне мал).

  • Используя OpenGL мы сможем выйти за рамки Android и небольшими усилиями переиспользовать решение на вебе с помощью WebGL.

Итак, мы разобрались, что такое OpenGL, для чего он нужен и почему подходит нам. Теперь посмотрим, как мы можем отправлять GPU команды, иными словами, воспользоваться имеющимися функциями. Для этого используются шейдеры – небольшие программы, написанные на специальных языках (GLSL для OpenGL), которые выполняются на GPU. Шейдеры бывают нескольких типов, с двумя из которых мы будем работать и о которых пойдёт дальше речь – вершинные и фрагментные.

Начать следует с того, что вся графика строится из примитивов: точек, линий и треугольников. Соответственно, чтобы нарисовать любую из этих фигур, нам понадобится знать их координаты (координаты вершин) и цвет. За первое отвечают вершинные шейдеры, а за второе – фрагментные. Если говорить более детально, то:

Вершинные шейдеры (Vertex shaders):

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

  • Они обычно используются для преобразования вершинных данных в пространстве модели в пространство отсечения или экрана.

Фрагментные шейдеры (Fragment shaders):

  • Фрагментные шейдеры работают на уровне фрагментов, которые генерируются после растеризации примитивов и определяют цвет и другие атрибуты пикселей на экране. Другими словами, фрагментный шейдер вызывается для каждого пикселя, определяя, как он будет отображён на экране.

  • Они могут выполнять операции, такие как наложение текстур, освещение, применение эффектов и т.д.

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

Если вы никогда раньше не встречались с шейдерами и языком GLSL в частности, вам наверняка уже стало интересно, как он выглядит. GLSL – С-подобный язык. Обязательный минимум любого шейдера – функция main().

void main() {
    gl_Position = a_Position;
}

Java – тоже C-подобный язык, поэтому у вас точно не возникнет сложностей в понимании и написании GLSL-кода, если вы работаете с Android. Примерно в том же стиле мы можем объявлять переменные, создавать функции, проводить арифметические операции. Более того, GLSL предоставляет набор встроенных структур и функций для работы с данными. К примеру, есть типичные min(), max() и clamp(), а парные значения (например, координаты) мы можем удобно хранить в виде встроенной структуры vec2. Совсем скоро мы напишем свой собственный шейдер, а пока двинемся дальше.

Итак, для работы с GPU мы можем использовать OpenGL, написав инструкции (шейдеры) на языке GLSL. Чтобы приступить к реализации задуманного эффекта, нам остаётся разобраться в том, как взаимодействовать с OpenGL из Android-кода. Как я упоминал, работа с OpenGL в Android возможна прямо из коробки: для этих целей существуют GLSurfaceView и GLSurfaceView.Renderer.

GLSurfaceView – это View, которая создаёт OpenGL-контекст, предоставляет поверхность (surface), на которой можно рисовать графику, а также обеспечивает синхронизацию между потоками, чтобы рендеринг выполнялся правильно в контексте жизненного цикла Activity.

В то время, как GLSurfaceView подготавливает нам всё необходимое для рисования, GLSurfaceView.Renderer отвечает за само рисование. Это интерфейс, который содержит три метода:

  • void onSurfaceCreated(GL10 gl, EGLConfig config) – вызывается при создании (пересоздании) OpenGL-контекста. Это подходящее место для инициализации OpenGL-параметров, загрузки текстур, шейдеров и т.д.

  • void onSurfaceChanged(GL10 gl, int width, int height) – вызывается при изменении размеров поверхности. Если мы используем эти значения в шейдере для каких-либо вычислений, мы можем задать/обновить их здесь.

  • void onDrawFrame(GL10 gl) – самое время отрисовать наш кадр. Вся магия рисования сцены должна происходить здесь.

Важно отметить, что все методы GLSurfaceView.Renderer вызываются в отдельном потоке. Об этом стоит помнить, когда мы хотим взаимодействовать с Renderer из UI-потока, например, при передаче событий касаний. Для этих целей GLSurfaceView имеет специальный метод queueEvent(Runnable r).

Подготовка рабочей поверхности

Пожалуй, пока теории будет достаточно, и мы можем переходить к практической части. Начнём с настройки GLSurfaceView. Разместим её вверху нашей View-иерархии (в моём случае, это Activity), внутри корневого контейнера (у меня FrameLayout), образуя тем самым оверлей. В методе onCreate() родительской Activity мы проводим инициализацию:

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
    …
    glSurfaceView.setEGLContextClientVersion(2);
    glSurfaceView.setZOrderOnTop(true);
    glSurfaceView.setEGLConfigChooser(8, 8, 8, 8, 16, 0);
    glSurfaceView.getHolder().setFormat(PixelFormat.RGBA_8888);
    glSurfaceView.setRenderer(new DustRenderer(this));
}

Работаем мы со следующими параметрами:

  • setEGLContextClientVersion() – установка версии OpenGL-контекста. Мы используем OpenGL ES 2.0.

  • setZOrderOnTop() – отрисовка графики поверх другого контента на экране. Мы хотим, чтобы частицы отображались поверх других элементов на экране.

  • setEGLConfigChooser() – установка размеров цветовых каналов RGBA (в нашем случае, 8 бит), буфера глубины и буфера шаблона.

  • getHolder().setFormat() – установка формата пикселей поверхности (те же 8 бит на каждый канал).

  • setRenderer() – установка GLSurfaceView.Renderer. Передаём наш DustRenderer, с которым будем работать дальше.

Не забываем привязаться к жизненному циклу:

@Override
protected void onResume() {
    super.onResume();
    glSurfaceView.onResume();
}

@Override
protected void onPause() {
    super.onPause();
    glSurfaceView.onPause();
}

Создадим DustRenderer и пока оставим пустым:

public class DustRenderer implements GLSurfaceView.Renderer {
    @Override
    public void onSurfaceCreated(GL10 gl, EGLConfig config) {
        
    }

    @Override
    public void onSurfaceChanged(GL10 gl, int width, int height) {

    }

    @Override
    public void onDrawFrame(GL10 gl) {

    }
}

Планирование

Всё готово к тому, чтобы начать рисовать. Начнём с шейдеров. Как я упоминал выше, нам понадобятся вершинный и фрагментный шейдеры. Создадим файлы particles_vert.glsl и particles_frag.glsl в папке app/res/raw. К слову, расширение файла не имеет значения и чаще можно встретить файлы с расширением .vert и .frag, но поскольку мы будем использовать эти файлы как файлы ресурсов, нужно, чтобы они отличались по имени. Для подсветки и проверки синтаксиса я рекомендую использовать расширение Android Studio.

Итак, какие же задачи стоят перед шейдерами?

Вершинный шейдер будет:

  • Вычислять относительную позицию частицы

  • Вычислять конечную позицию

  • Интерполировать позицию от начальной до конечной с течением времени

В то же время фрагментный шейдер будет:

  • Определять цвет частицы по её позиции относительно анимируемого элемента

Композиция

Приступаем к работе над вершинным шейдером и начнём с параметров. Мы можем передавать данные в шейдер извне, а также делиться данными непосредственно между шейдерами. Мы будем работать с тремя типами параметров:

  • Вершинные атрибуты. Они связаны с каждой вершиной и служат для передачи данных, которые меняются от вершины к вершине, например, позиции вершин, текстурные координаты и т.п. Объявляются в вершинных шейдерах с использованием ключевого слова attribute.

  • Uniform-переменные. Хранят данные, которые не зависят от вершины. Например, мы можем передать текущее время или разрешение поверхности. Объявляются в шейдерах с использованием ключевого слова uniform.

  • Varying-переменные. Служат для передачи данных от вершинного шейдера фрагментному. Например, нормализованные координаты вершины. Объявляются в вершинных шейдерах с использованием ключевого слова varying и имеют аналог во фрагментном.

Разобравшись в типах, объявим переменные в вершинном шейдере:

precision highp float;

uniform float u_AnimationDuration;
uniform float u_ParticleSize;
uniform float u_ElapsedTime;
uniform float u_ViewportWidth;
uniform float u_ViewportHeight;
uniform float u_TextureWidth;
uniform float u_TextureHeight;
uniform float u_TextureLeft;
uniform float u_TextureTop;

attribute float a_ParticleIndex;

varying vec2 v_ParticleCoord;
varying float v_ParticleLifetime;

Обратите внимание на первую строку. В OpenGL мы можем указать уровень точности для целых чисел и чисел с плавающей точкой. Она определяет, сколько бит используется для представления чисел, а, следовательно, уровень точности в вычислениях. Существует три уровня: lowp, mediump и highp. Чем выше уровень, тем больше памяти расходуется, производительность может быть ниже, но тем точнее будет результат. Мы можем указывать уровень точности как для всех переменных шейдера нужного типа (наш случай), так и по месту использования. Для этого проекта я выбрал высокий уровень точности эмпирически: на некоторых устройствах анимация, а именно расчёт позиции точки, работала корректно только на этом уровне. Обычно хватает mediump.

Вернёмся к переменным. В качестве uniform-переменных мы объявляем общее время нашей анимации, размер частицы, время, прошедшее от начала анимации, а также размер поверхности, текстуры и её смещение. Часть из этих переменных может меняться (например, время), однако все они неизменны для всех наших вершин в рамках одного кадра.

Две varying-переменные понадобятся нам позже во фрагментном шейдере, а пока остановимся на единственном атрибуте a_ParticleIndex: что это за индекс и откуда он берётся. Забегая вперёд, скажу, что на частицы мы будем разбивать изображение – «фотографию» удаляемого View-элемента. Изображение есть ни что иное, как набор пикселей в виде двумерного массива. Мы можем рассматривать каждую ячейку такого массива как вершину, где относительными координатами будет являться её индекс в формате [x, y]. Конвейер OpenGL принимает только одномерные массивы атрибутов, поэтому мы можем превратить имеющийся двумерный массив в одномерный формата [x1, y1, x2, y2, … , xN, yN]:

При передаче его OpenGL мы укажем, что у нас N вершин и на каждую приходится два значения. С точки зрения шейдера это удобно: мы сразу получаем индекс вершины и легко можем вычислить абсолютную позицию частицы на экране, зная его размеры. Однако стоит помнить, что для N частиц мы получаем массив размером 2N. При больших значениях N подготовка массива может занять достаточно времени, чтобы вызвать задержку между запросом на старт и непосредственно стартом анимации. Ну и памяти, конечно, займёт в 2 раза больше. Именно по этой причине более оптимальным будет для каждой частицы хранить и передавать одно значение вместо двух: значение в каждой ячейке массива будет равно её индекс.

Таким образом мы сократим время на создание массива частиц, при этом по-прежнему сможем вычислить значения x и y уже внутри шейдера, что будет выполнено параллельно сразу для сотен частиц.

Небольшой факт. Подобная манипуляция с массивом нам нужна только в рамках OpenGL ES 2.0. В OpenGL ES 3.0 мы можем получить такое значение, воспользовавшись встроенной переменной gl_VertexID.

Мы определились с данными, которые нам пригодятся, приступаем непосредственно к анимации. Идея достаточно проста: частицы движутся по прямой траектории от своей начальной позиции до случайно сгенерированной конечной. Рассмотрим подробнее.

Сначала мы вычисляем абсолютную позицию нашей частицы (вершины) с помощью метода getPositionFromIndex():

void main() {
    vec2 position = getPositionFromIndex(u_ParticleSize, a_ParticleIndex);
    ...
}

vec2 getPositionFromIndex(float particleSize, float index) {
    float y = floor(index / u_TextureWidth);
    float x = index - y * u_TextureWidth;
    return vec2(
        particleSize * (x + 0.5) + u_TextureLeft,
        particleSize * (y + 0.5) + u_TextureTop
    );
}

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

Хочу обратить ваше внимание на один интересный момент. Работая с Java / Kotlin мы привыкли, что при использовании чисел с плавающей точкой типом по умолчанию является double, а чтобы воспользоваться типом float, нам нужен специальный суффикс: 5.0 – double, 5.0f – float. В GLSL типом по умолчанию является float, поэтому суффикс f не требуется. Более того, использование суффикса может привести к ошибке компиляции шейдера: суффикс не является частью спецификации, а потому разработчики GPU могут добавлять или не добавлять его поддержку на своё усмотрение. Я по привычке и по незнанию дописывал суффикс, и это работало на основном тестовом устройстве (Pixel), однако это привело к ошибкам при более широком тестировании на других устройствах (Samsung).

Определив позицию, вычисляем задержку particleAnimationDelay. Мы хотим, чтобы частицы разлетались не одновременно, а постепенно, слева-направо. particleAnimationDelay участвует в вычислении параметра particleLifetime, изменяющийся от 0 до 1 и растущий тем медленнее, чем правее находится частица. Иными словами, время жизни правых частиц больше, чем левых.

void main() {
    ...
    float particleAnimationMinTime = u_AnimationDuration / 4.0;
    float particleAnimationTotalTime = particleAnimationMinTime * (1.0 + r);
    float particleAnimationDelay = position.x / u_ViewportWidth * particleAnimationMinTime;

    // Allow particles to have different time of life
    float particleLifetime = min(u_ElapsedTime / (particleAnimationDelay + particleAnimationTotalTime), 1.0);
    ...
}

Если мы будем использовать particleLifetime в качестве factor-параметра интерполяции, наше изображение сразу рассыплется на множество частиц и начнёт угасать слева-направо. Мы же хотим добиться более постепенного растворения: частицы справа должны начать медленное движение в выбранном направлении, постепенно ускоряясь, догоняя по пройденному расстоянию левые и растворяясь практически одновременно с ними. Здесь нам на помощь придёт степенная функция x^n: чем больше будет n, тем медленнее будет старт и тем быстрее финиш. Значение n будет также расти в зависимости от положения частицы: чем правее частица, тем больше степень (параметр acceleration).

График функции x^n при n равных 1, 2, 3 и 4.
График функции x^n при n равных 1, 2, 3 и 4.

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

void main() {
    ...
    float acceleration = 1.0 + 3.0 * (position.x / u_ViewportWidth);

    gl_Position = calculatePosition(position, r, pow(particleLifetime, acceleration));
    ...
}

Рассчитав абсолютную позицию, а также factor-параметр интерполяции, мы можем рассчитать текущую относительную позицию частицы с помощью метода calculatePosition():

vec4 calculatePosition(vec2 position, float r, float factor) {
    float normalizedX = normalizeX(position.x);
    float normalizedY = normalizeY(position.y);
    float x = interpolateLinear(
        normalizedX,
    // The formula was chosen empirically
        normalizedX + (fract(10000.0 * random(normalizedY) * random(normalizedY) * r) - 0.5),
        factor
    );
    float y = interpolateLinear(
        normalizedY,
    // The formula was chosen empirically
        normalizedY + (fract(100000.0 * random(normalizedX) * random(normalizedX) * r) - 0.25),
        factor
    );
    return vec4(x, y, 0.0, 1.0);
}

Здесь мы остановимся на двух вещах: нормализации и рандоме.

Когда мы работаем с Custom View, мы используем рамки относительно родительского контейнера или экрана. Обычно эти значения меняются от 0 до заданной ширины / высоты. В OpenGL мы используем другую систему координат: значения по горизонтали и вертикали меняются от -1 до 1.

Хотя диапазон значений по обеим осям одинаков, экран редко когда сейчас имеет одинаковую высоту и ширину, а потому учитываем эти значения при нормализации координат из экранных в поверхностные:

float normalizeX(float x) {
    return 2.0 * x / u_ViewportWidth - 1.0;
}

float normalizeY(float y) {
    return 1.0 - 2.0 * y / u_ViewportHeight;
}

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

По-хорошему, для правильного позиционирования объектов в координатной системе поверхности используется проекционная матрица, но в рамках данного проекта в ознакомительных целях мы считаем координаты руками.

Вернёмся к рандому. Посмотрите на функцию ниже:

float random(float v) {
    return fract(sin(v) * 100000.0);
}

Здесь мы вычисляем значение синуса от v, умножаем на 100000 и извлекаем дробную часть из полученного числа. Как вы думаете, можно ли число, полученное таким образом, назвать случайным? Пожалуй, нет. Давайте взглянет на график:

Можно заметить артефакты в точках максимумов синусоиды.
Можно заметить артефакты в точках максимумов синусоиды.

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

Хотя мы и применяем описанную выше функцию, в первую очередь мы всё же полагаемся на другую версию рандома:

float random(vec2 st) {
    return fract(sin(dot(st.xy,
                         vec2(12.9898,78.233)))
                 * 43758.5453123);
}

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

float random(float v, float mult) {
    return fract(10000.0 * random(v) * random(v) * mult);
}

Рассчитав относительную позицию частицы мы можем, наконец, передать её OpenGL, назначив встроенную переменную gl_Position типа vec4. Именно поэтому мы дополняем наши параметры x и y двумя дополнительными z и w, указывая постоянные дефолтные значения.

gl_Position – лишь одна из многих встроенных переменных. К примеру, мы также устанавливаем gl_PointSize – переменную, отвечающую за размер отображаемой точки при рендеринге точечных примитивов. Иными словами, мы устанавливаем размер нашей частицы, передав это значение извне с помощью uniform-переменной. Чуть позже, во фрагментном шейдере, мы узнаем, на что оно влияет.

Работа с цветом

Мы закончили, пожалуй, с самой мудрёной частью – вершинным шейдером. Последнее, что нам осталось здесь сделать, – задать две varying-переменные, которые будут переданы во фрагментный шейдер:

v_ParticleLifetime = particleLifetime;

vec2 textureOffset = vec2(u_TextureLeft, u_TextureTop);
vec2 originalTextureSize = vec2(u_TextureWidth * u_ParticleSize, u_TextureHeight * u_ParticleSize);
// Calculate particle coordinate on texture
v_ParticleCoord = (position - textureOffset) / originalTextureSize;

Первая – v_ParticleLifetime – просто принимает ранее вычисленное значение particleLifetime, которое поможет нам в интерполяции, но уже не позиции, а степени угасания. Вторая – v_ParticleCoord – хранит в себе координаты частицы относительно текстуры. Она нам понадобится во фрагментном шейдере для того, чтобы определить цвет нашей частицы. Текстурная система измерения уже больше похожа на привычную нам экранную, с той лишь разницей, что значения меняются от 0 до 1.

Снаружи - координаты вершин квадрата относительно поверхности, внутри - относительно текстуры.
Снаружи - координаты вершин квадрата относительно поверхности, внутри - относительно текстуры.

Переходим к фрагментному шейдеру. Здесь мы выполняем следующее:

  • Определяем цвет частицы (вершины / точки).

  • Интерполируем площадь отображения и прозрачность.

  • Применяем полученные значения.

precision mediump float;

uniform sampler2D u_Texture;
varying vec2 v_ParticleCoord;
varying float v_ParticleLifetime;

void main() {
    if (v_ParticleLifetime == 1.0) {
        discard;
    }

    vec4 textureColor = texture2D(u_Texture, v_ParticleCoord);
    if (textureColor.a == 0.0) {
        discard;
    }

    vec2 center = vec2(0.5, 0.5);
    float distanceToCenter = distance(center, gl_PointCoord);
    float visibilityFactor = pow(v_ParticleLifetime, 5.);
    if (distanceToCenter > 1.0 - visibilityFactor) {
        discard;
    }

    float alpha = interpolateLinear(textureColor.a, 0.0, visibilityFactor);
    gl_FragColor = vec4(textureColor.xyz, alpha);
}

Первое, что мы видим, – передача текстуры в шейдер в виде uniform-переменной. Ниже по координатам мы получаем цвет нашей частицы. Тут может возникнуть вопрос: если размер частицы равен одному, то каждая частица соответствует пикселю изображения. Но что, если размер частицы больше, например, 3? Давайте разбираться.

Когда размер нашей частицы (точки) больше 1, мы работаем уже не с пикселем, а с массивом пикселей. Так, для размера точки 3 у нас будет массив 3x3. Мы учитывали размер частицы при определении относительной позиции частицы, поэтому в случае с размером 3 мы получим координаты не первого пикселя в данном массиве, а пятого с индексом [1, 1]. По этим координатам мы и запрашиваем цвет у текстуры. Всё остальное OpenGL берёт на себя: зная центр, он сам определяет массив этих пикселей. Но мы же получаем один цвет, а не массив, верно? Дело в том, что OpenGL помогает и здесь. Он самостоятельно вычисляет среднее значение из 9 цветов и возвращает его нам.

Большой квадрат - наше изображение, красный - частица размером 3x3.
Большой квадрат - наше изображение, красный - частица размером 3x3.

Хорошо, формально мы поменяли разрешение нашего изображения. Но не поменялось количество пикселей экрана, задействованного для отображения. Как тогда все эти пиксели отобразятся на экране, если цвет один, координаты одни, а пикселей 9? Опять же, OpenGL самостоятельно выполнит вызов фрагментном шейдера для всех 9 пикселей, и мы получим одинаковое поведение / отображение для всех из них.

С помощью этой возможности мы реализуем ещё один эффект – постепенное угасание частицы. С течением времени (технически, в зависимости от параметра v_ParticleLifetime) мы будем уменьшать размер частицы вплоть до нуля. Для этого нам нужно найти расстояние текущего пикселя от центра частицы, и если оно больше, чем заданное значение, то мы попросту не рисуем этот пиксель. Координаты пикселя относительно частицы мы получаем с помощью встроенной переменной gl_PointCoord. Система координат относительно частицы такая же, как и у текстуры: [0.0, 0.0] – левый верхний край, [1.0, 1.0] – правого нижнего, [0.5, 0.5] – центр.

vec2 center = vec2(0.5, 0.5);
float distanceToCenter = distance(center, gl_PointCoord);
float visibilityFactor = pow(v_ParticleLifetime, 5.);
if (distanceToCenter > 1.0 - visibilityFactor) {
    discard;
}

Таким образом мы "закругляем" нашу квадратную частицу, постепенно уменьшая радиус.

Изменение размера частицы с течением времени.
Изменение размера частицы с течением времени.

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

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

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

Наполнение

Чтобы наши шейдеры работали вместе, их нужно связать в единую программу. Для этого создадим утилитный класс ShaderUtils:

public class ShaderUtils {

    public static int createProgram(int vertexShaderId, int fragmentShaderId) {
        final int programId = glCreateProgram();
        if (programId == 0) {
            return 0;
        }
        glAttachShader(programId, vertexShaderId);
        glAttachShader(programId, fragmentShaderId);
        glLinkProgram(programId);
        final int[] linkStatus = new int[1];
        glGetProgramiv(programId, GL_LINK_STATUS, linkStatus, 0);
        if (linkStatus[0] == 0) {
            glDeleteProgram(programId);
            return 0;
        }
        return programId;
    }

    public static int createShader(Context context, int type, int shaderRawId) {
        String shaderText = FileUtils.readTextFromRaw(context, shaderRawId);
        return ShaderUtils.createShader(type, shaderText);
    }

    public static int createShader(int type, String shaderText) {
        final int shaderId = glCreateShader(type);
        if (shaderId == 0) {
            return 0;
        }
        glShaderSource(shaderId, shaderText);
        glCompileShader(shaderId);
        final int[] compileStatus = new int[1];
        glGetShaderiv(shaderId, GL_COMPILE_STATUS, compileStatus, 0);
        if (compileStatus[0] == 0) {
            Log.e("ShaderCreation", "Shader creation failure. Reason: " + glGetShaderInfoLog(shaderId));
            glDeleteShader(shaderId);
            return 0;
        }
        return shaderId;
    }

}

Здесь вы можете видеть статические методы класса GLES20. Особенностью данных методов является то, что по умолчанию они должны вызываться на потоке рендера. Иными словами, в одном из трёх методов GLSurfaceView.Renderer.

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

Вернёмся к DustRenderer, в частности, методу onSurfaceCreated():

@Override
public void onSurfaceCreated(GL10 arg0, EGLConfig arg1) {
    glEnable(GL_BLEND);
    glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

    int particlesVertexShaderId = ShaderUtils.createShader(context, GL_VERTEX_SHADER, R.raw.particles_vert);
    int particlesFragmentShaderId = ShaderUtils.createShader(context, GL_FRAGMENT_SHADER, R.raw.particles_frag);
    particlesProgramId = ShaderUtils.createProgram(particlesVertexShaderId, particlesFragmentShaderId);

    aParticleIndex = glGetAttribLocation(particlesProgramId, "a_ParticleIndex");
}

Во-первых, мы подключаем смешивание. Наши частицы будут превращаться из непрозрачных в полностью прозрачные, а потому хотим, чтобы цвет частицы «смешивался» с цветом других частиц и контента под ними.

Наложение частиц друг на друга.
Наложение частиц друг на друга.

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

Переходим к методу onSurfaceChanged():

@Override
public void onSurfaceChanged(GL10 arg0, int width, int height) {
    glViewport(0, 0, width, height);
    glUseProgram(particlesProgramId);
    glUniform1f("u_ViewportWidth", width);
    glUniform1f("u_ViewportHeight", height);
}

Каждый раз, когда вызывается этот метод, мы получаем новые размеры поверхности, а значит, сразу же передаём их в программу. Мы используем свою реализацию метода glUniform1f():

private void glUniform1f(@NonNull String name, float param) {
    int location = glGetUniformLocation(particlesProgramId, name);
    GLES20.glUniform1f(location, param);
}

Здесь стоит обратить внимание на различие в получении локации атрибута и uniform-переменной. Для атрибута мы сразу же определили и сохранили локацию, поскольку нам не нужно указывать активную программу. Для uniform-переменных мы должны вызвать glUseProgram() перед тем, как искать их локации и передавать значения. По этой причине мы определяем локацию для uniform-переменных непосредственно перед установкой значения. Такой механизм позволяет работать сразу с несколькими программами.

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

private static class RenderInfo {

    private final int particleSize;

    private int columnCount;
    private int rowCount;
    private float textureLeft;
    private float textureTop;

    @Nullable
    private Bitmap sourceBitmap;

    private int textureId;

    @Nullable
    private FloatBuffer particlesIndicesBuffer;

    private long animationStartTime = -1;
    private volatile boolean canBeRendered = true;
    private volatile boolean isReadyForRender;
    private boolean isTextureLoaded;


    public RenderInfo(int particleSize) {
        this.particleSize = particleSize;
    }


    public void loadTextureIfNeeded() {

    }

    public void composeView(@NonNull View view, @Nullable @Size(2) int[] offset) {

    }

    public void recycle() {

    }

}

Здесь вы можете видеть все те же параметры, которые мы объявляли в нашем вершинном шейдере, а также методы, которые помогут нам эти данные заполнить. Пойдем по порядку.

Мы начинаем с метода composeView(). У него три задачи: преобразовать View в Bitmap, подготовить массив индексов и заполнить нужные параметры. Первые две – достаточно затратные, поэтому будем выполнять их параллельно с помощью ExecutorService.

public void composeView(@NonNull View view, @Nullable @Size(2) int[] offset) {
    if (isReadyForRender) {
        // Only one-shot usage is allowed.
        return;
    }

    Rect viewVisibleRect = new Rect();
    boolean isViewVisible = view.getLocalVisibleRect(viewVisibleRect);

    if (!isViewVisible || viewVisibleRect.width() == 0 || viewVisibleRect.height() == 0) {
        canBeRendered = false;
        return;
    }

    int[] viewLocation = new int[2];
    view.getLocationOnScreen(viewLocation);

    columnCount = viewVisibleRect.width() / particleSize;
    rowCount = viewVisibleRect.height() / particleSize;
    textureLeft = viewLocation[0] + viewVisibleRect.left;
    textureTop = viewLocation[1] + viewVisibleRect.top;

    if (offset != null && offset.length >= 2) {
        textureLeft += offset[0];
        textureTop += offset[1];
    }

    ExecutorService executorService = Executors.newFixedThreadPool(2);

    executorService.submit(() -> {
        Bitmap viewBitmap = Bitmap.createBitmap(viewVisibleRect.width(), viewVisibleRect.height(), Bitmap.Config.ARGB_8888);
        Canvas c = new Canvas(viewBitmap);
        c.translate(-viewVisibleRect.left, -viewVisibleRect.top);
        view.draw(c);
        sourceBitmap = viewBitmap;
    });

    executorService.submit(() -> {
        int particlesCount = columnCount * rowCount;

        float[] particlesIndices = new float[particlesCount];
        for (int i = 0; i < particlesCount; i++) {
            particlesIndices[i] = i;
        }

        particlesIndicesBuffer = createFloatBuffer(particlesIndicesBuffer, particlesIndices);
    });

    executorService.shutdown();

    try {
        boolean terminated = executorService.awaitTermination(1, TimeUnit.MINUTES);
        if (!terminated) {
            executorService.shutdownNow();
        }
    } catch (InterruptedException ie) {
        ie.printStackTrace();
    }

    isReadyForRender = true;
}

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

Следующий метод – loadTextureIfNeeded():

public void loadTextureIfNeeded() {
    if (isTextureLoaded) {
        return;
    }

    Bitmap sourceBitmap = this.sourceBitmap;
    if (sourceBitmap == null || sourceBitmap.isRecycled()) {
        throw new IllegalStateException("Source bitmap can't be used: null or recycled.");
    }

    final int[] textureHandle = new int[1];
    glGenTextures(1, textureHandle, 0);

    if (textureHandle[0] != 0) {
        glBindTexture(GL_TEXTURE_2D, textureHandle[0]);

        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);

        GLUtils.texImage2D(GL_TEXTURE_2D, 0, sourceBitmap, 0);

        this.sourceBitmap = null;
    }

    if (textureHandle[0] == 0) {
        throw new RuntimeException("Error loading texture.");
    }

    textureId = textureHandle[0];
    isTextureLoaded = true;
}

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

Наконец, метод recycle():

public void recycle() {
    int[] textureHandle = { textureId };
    glDeleteTextures(1, textureHandle, 0);
}

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

Наш RenderInfo готов. Организуем очередь из RenderInfo-объектов, у которых будем брать данные для отрисовки. Поскольку обращения к этой очереди будут из разных потоков, воспользуемся ConcurrentLinkedQueue:

private final ConcurrentLinkedQueue<RenderInfo> renderInfos = new ConcurrentLinkedQueue<>();

Создавать RenderInfo и передавать ему View мы будем с помощью метода composeView() класса DustRenderer:

public void composeView(@NonNull View view, @Nullable @Size(2) int[] offset) {
    RenderInfo renderInfo = new RenderInfo(particleSize);
    renderInfos.add(renderInfo);
    renderInfo.composeView(view, offset);
}

Долгожданный метод onDrawFrame():

@Override
public void onDrawFrame(GL10 arg0) {
    glClear(GL_COLOR_BUFFER_BIT);

    if (renderInfos.isEmpty()) {
        return;
    }

    glUseProgram(particlesProgramId);

    glUniform1f("u_AnimationDuration", duration);
    glUniform1f("u_ParticleSize", particleSize);

    long currentTime = System.currentTimeMillis();
    for (Iterator<RenderInfo> iterator = renderInfos.iterator(); iterator.hasNext(); ) {
        RenderInfo renderInfo = iterator.next();
        if (!renderInfo.canBeRendered) {
            iterator.remove();
            continue;
        }

        if (!renderInfo.isReadyForRender) {
            continue;
        }
        renderInfo.loadTextureIfNeeded();
        boolean isFrameDrawn = drawFrame(renderInfo, currentTime);
        if (!isFrameDrawn) {
            renderInfo.recycle();
            iterator.remove();
        }
    }
}

Мы очищаем экран, передаём uniform-переменные времени анимации и размера частиц, которые не зависят от RenderInfo, после чего последовательно проходим по каждому RenderInfo из очереди (если она не пустая) и отрисовываем сцену на основе взятых из него данных. Если кадр не был отрисован (анимация завершена), удаляем RenderInfo из очереди.

Непосредственно метод отрисовки:

private boolean drawFrame(@NonNull RenderInfo renderInfo, long currentTime) {
    if (renderInfo.animationStartTime == -1) {
        renderInfo.animationStartTime = System.currentTimeMillis();
    }

    long elapsedTime = currentTime - renderInfo.animationStartTime;
    if (elapsedTime > duration) {
        return false;
    }

    int uTexture = glGetUniformLocation(particlesProgramId, "u_Texture");
    glUniform1i(uTexture, 0);

    glActiveTexture(GL_TEXTURE0);
    glBindTexture(GL_TEXTURE_2D, renderInfo.textureId);

    glUniform1f("u_ElapsedTime", elapsedTime);

    glUniform1f("u_TextureWidth", renderInfo.columnCount);
    glUniform1f("u_TextureHeight", renderInfo.rowCount);
    glUniform1f("u_TextureLeft", renderInfo.textureLeft);
    glUniform1f("u_TextureTop", renderInfo.textureTop);

    glVertexAttribPointer(aParticleIndex, 1, GL_FLOAT, false, 0, renderInfo.particlesIndicesBuffer);
    glEnableVertexAttribArray(aParticleIndex);

    glDrawArrays(GL_POINTS, 0, renderInfo.columnCount * renderInfo.rowCount);

    glDisableVertexAttribArray(aParticleIndex);

    return true;
}

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

glVertexAttribPointer(aParticleIndex, 1, GL_FLOAT, false, 0, renderInfo.particlesIndicesBuffer);

Здесь мы также указываем, какой тип данных, нормализованы ли они, смещение и непосредственно сам буфер, где хранится наш массив.

Отрисовка вершин вызывается с помощью метода glDrawArrays(), в котором мы указываем тип примитива (в нашем случае, точка), индекс первого, а также их количество:

glDrawArrays(GL_POINTS, 0, renderInfo.columnCount * renderInfo.rowCount);

Оценка результата

Всё готово! Запускаем приложение и наблюдаем эффектное исчезновение маленьких роботов:

Pixel 5a, тестовое приложение. Размер частиц - 1 пиксель, продолжительность анимации - 1800мс.
Pixel 5a, тестовое приложение. Размер частиц - 1 пиксель, продолжительность анимации - 1800мс.

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

Как вы помните, одной из причин выбора OpenGL ES 2.0 был интерес попробовать запустить эффект на старых версиях Android. К сожалению, у меня не осталось живых устройств с 4-ой, а тем более со 2-ой версией, но, к счастью, у нас есть эмулятор. В студии можно загрузить Android 2.3, однако по какой-то причине эмулятор с ним не запустился, поэтому потестим на 4.0.3:

Эмулятор Nexus S с Android 4.0.3 (API 15).
Эмулятор Nexus S с Android 4.0.3 (API 15).

Стоит отметить, что производительность эффекта на эмуляторе не является показательной, поскольку для вычислений используется GPU ПК, однако в данном случае нам важно лишь подтвердить сам факт работоспособности: на реальном устройстве со слабым GPU просадки ожидаемы, однако повысить производительность мы можем путём увеличения размера частиц.

Поговорим о производительности в целом. На моём Pixel 5a с разрешением 1080x2400 эффект отрабатывает без просадок для картинок, занимающих 60-70% экрана, при размере частиц в 1 пиксель. Для контента побольше понадобится увеличить размер частиц до 2-3. На более старом и слабом Samsung Galaxy J7 c разрешением 1080x1920 размер частиц в 1 пиксель хорошо работает для мелких и средних по размеру элементов, для крупных также подходит размер в 3 пикселя. Для большей наглядности, как и обещал, сравним полученный эффект с тем, что используется в официальной версии Telegram. В качестве теста удалим две гифки, которые будут занимать практически всё рабочее пространство:

Pixel 5a. Размер частиц - 2 пикселя.
Pixel 5a. Размер частиц - 2 пикселя.

И снова скриншоты с большей детализацией:

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

Приятным бонусом использования OpenGL стала возможность переиспользовать наши шейдеры и повторить эффект на Web с применением WebGL:

Размер частиц - 1 пиксель, продолжительность анимации - 3600мс.
Размер частиц - 1 пиксель, продолжительность анимации - 3600мс.

Хочу подсветить, что я знаком с JS максимально поверхностно, однако перенести логику из Java в JS было достаточно легко, поскольку мы по сути работаем с одними и теми же методами. Думаю, вы без проблем сможете провести аналогию между тем, что мы писали под Android, и тем, что написали под Web, поэтому позволю себе оставить этот код без комментариев.

Код эффекта на JS
const baseUrl = 'http://localhost:5500/';
const duration = 3600;
const particleSize = 1;

var gl = null;
var program = null;
var animationStartTime = -1;
var particlesCount = 0;

async function loadFileContent(path) {
    const response = await fetch(baseUrl + path);
    return await response.text();
}

async function main() {
    const canvas = document.getElementById('canvas');
    gl = canvas.getContext('webgl');

    if (!gl) {
        alert('Your browser doesn\'t support WebGL :(');
    }

    await init();

    const removableElement = document.getElementById('removable');
    removableElement.addEventListener('click', () => removeElementWithAnimation(removableElement));
}

async function init() {
    resizeCanvasToDisplaySize(gl.canvas);

    gl.enable(gl.BLEND);
    gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);

    gl.clearColor(0, 0, 0, 0);
    gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);

    program = await createProgram(gl, 'shaders/particles_vert.glsl', 'shaders/particles_frag.glsl');
    gl.useProgram(program);
}

function resizeCanvasToDisplaySize(canvas) {
    // Lookup the size the browser is displaying the canvas in CSS pixels.
    const displayWidth  = canvas.clientWidth;
    const displayHeight = canvas.clientHeight;
   
    const needResize = canvas.width  !== displayWidth ||
                       canvas.height !== displayHeight;
   
    if (needResize) {
        canvas.width  = displayWidth;
        canvas.height = displayHeight;
    }
   
    return needResize;
}

async function createProgram(gl, vertexShaderPath, fragmentShaderPath) {
    const vertexShaderContent = await loadFileContent(vertexShaderPath);
    const fragmentShaderContent = await loadFileContent(fragmentShaderPath);

    const vertexShader = compileShader(gl, vertexShaderContent, gl.VERTEX_SHADER);
    const fragmentShader = compileShader(gl, fragmentShaderContent, gl.FRAGMENT_SHADER);

    const program = gl.createProgram();
    gl.attachShader(program, vertexShader);
    gl.attachShader(program, fragmentShader);
    gl.linkProgram(program);

    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
        console.error('Shader program initialization failure:', gl.getProgramInfoLog(program));
    }

    return program;
}

function compileShader(gl, source, type) {
    const shader = gl.createShader(type);
    gl.shaderSource(shader, source);
    gl.compileShader(shader);

    if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
        console.error('Shader compilation failure:', gl.getShaderInfoLog(shader));
        gl.deleteShader(shader);
        return null;
    }

    return shader;
}

function removeElementWithAnimation(element) {
    const image = new Image();
    image.onload = () => {
        loadTexture(image);
        bindParameters(element)
        requestDraw();
        element.parentNode.removeChild(element);
    };
    html2canvas(element, { scale: 2 }).then(canvas => {
        image.src = canvas.toDataURL('image/png');
    });
}

function loadTexture(image) {
    const imageData = getImageData(image);

    var texture = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, texture);

    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);

    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, imageData);

    const textureLocation = gl.getUniformLocation(program, 'u_Texture');
    gl.uniform1i(textureLocation, 0);
}

function getImageData(image) {
    const canvas = document.createElement('canvas');
    canvas.width = image.width;
    canvas.height = image.height;

    const context = canvas.getContext('2d');
    context.drawImage(image, 0, 0);
    return context.getImageData(0, 0, canvas.width, canvas.height);
}

function bindParameters(element) {
    const rect = element.getBoundingClientRect();
    const textureWidth = Math.round(rect.width / particleSize);
    const textureHeight = Math.round(rect.height / particleSize);
    const textureLeft = element.offsetLeft;
    const textureTop = element.offsetTop;
    particlesCount = textureWidth * textureHeight;

    glUniform1f(gl, program, 'u_AnimationDuration', duration);
    glUniform1f(gl, program, 'u_ParticleSize', particleSize);
    glUniform1f(gl, program, 'u_ViewportWidth', window.innerWidth);
    glUniform1f(gl, program, 'u_ViewportHeight', window.innerHeight);
    glUniform1f(gl, program, 'u_TextureWidth', textureWidth);
    glUniform1f(gl, program, 'u_TextureHeight', textureHeight);
    glUniform1f(gl, program, 'u_TextureLeft', textureLeft);
    glUniform1f(gl, program, 'u_TextureTop', textureTop);

    const particleIndices = new Array(particlesCount);
    for (let i = 0; i < particlesCount; i++) {
        particleIndices[i] = i;
    }

    const particleIndicesBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, particleIndicesBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(particleIndices), gl.STATIC_DRAW);

    const particleIndexAttrLocation = gl.getAttribLocation(program, 'a_ParticleIndex');
    gl.enableVertexAttribArray(particleIndexAttrLocation);
    gl.vertexAttribPointer(particleIndexAttrLocation, 1, gl.FLOAT, false, 0, 0);
}

function requestDraw() {
    requestAnimationFrame(draw)
}

function draw(time) {
    gl.clear(gl.COLOR_BUFFER_BIT);

    if (animationStartTime == -1) {
        animationStartTime = time;
    }

    const currentTime = time;
    const elapsedTime = currentTime - animationStartTime;
    if (elapsedTime > duration) {
        animationStartTime = -1;
        return;
    }

    glUniform1f(gl, program, 'u_ElapsedTime', elapsedTime);

    gl.drawArrays(gl.POINTS, 0, particlesCount);

    requestDraw();
}

function glUniform1f(gl, program, name, value) {
    const location = gl.getUniformLocation(program, name);
    gl.uniform1f(location, value);
}

window.onload = main;

Дивный мир шейдеров

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

blades by gman

blades
blades

technoball by demoniak

technoball
technoball

run by gman

run
run

Если вы читаете с десктопной версии браузера, настоятельно рекомендую перейти по ссылкам и ознакомиться с этими и рядом других произведений. Много где эффекты работают в паре с аудиодорожками SoundСloud, отчего происходящее на экране выглядит ещё эффектнее.

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

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

Полезные ссылки

Tags:
Hubs:
Total votes 23: ↑23 and ↓0+23
Comments9

Articles