Connect with us

Разработка

Эффекты с GPU-ускорением: глитч в масштабе

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

Опубликовано

/

     
     

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

Когда дело доходит до графики, очень важно выбрать правильный инструмент для работы, поскольку очень легко достичь потолка производительности и сложности в масштабировании решение. Так ли это в данном случае? Давайте посмотрим!

Приготовьтесь, мы будем заниматься низкоуровневыми вещами.

Природа шейдеров

Итак, что же такое шейдеры? Шейдер — это программа, которая выполняется непосредственно на графическом процессоре и выполняется параллельно. Шейдеры обычно пишутся на специальном языке, похожем на C. В случае Compose на Android это AGSL — Android Graphics Shading Language.

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

Итак, в чём же разница с ЦП? Ключевое различие между центральным и графическим процессором заключается в следующем:

Эффекты с GPU-ускорением: глитч в масштабе

Центральный процессор:

  • Более сложный
  • Разработан для больших программ, выполняющих множество различных задач
  • MIMD (Multiple Instruction — Multiple Data)

Графический процессор:

  • Намного проще (без предсказания ветвлений, меньший объём кэша)
  • Разработан для небольших программ, выполняющих одни и те же операции с различными данными
  • Больше ядер = более высокий параллелизм
  • SIMD (Single Instruction — Multiple Data)

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

Эффекты с GPU-ускорением: глитч в масштабе

Графический процессор отлично подходит для выполнения одной и той же операции над миллионами пикселей.

Очень важный сдвиг в ментальной модели: раньше у нас был canvas, на котором можно было рисовать в любом месте, а теперь у нас есть изображение, из которого можно сэмплировать (читать) любую часть, но результатом работы будет один пиксель. Один и тот же шейдер выполняется для каждого целевого пикселя. Это похоже на чистую функцию — никаких побочных эффектов, каждый пиксель зависит только от своих координат и uniform-параметров, которые были переданы.

Анализ

Давайте перейдём к реализации, но сначала проанализируем ключевые моменты исходной версии Compose и перенесём эти идеи в ментальную модель шейдера.

Ключевым драйвером анимации является шаг. Animatable подсчитывает значения с плавающей точкой от 10 до 0 за период 500 мс. Состояние шага — целое число, и при преобразовании числа с плавающей точкой в ​​целое число получается 11 шагов.

var step by remember { mutableStateOf(0) }
LaunchedEffect(key) {
   Animatable(10f)
       .animateTo(
           targetValue = 0f,
           animationSpec = tween(
               durationMillis = 500,
               easing = LinearEasing,
           )
       ) {
           step = this.value.roundToInt()
       }
}

Затем есть дополнительный параметр, называемый интенсивностью, который рассчитывается на основе шага:

val intensity = step / 10f

Таким образом, интенсивность представляет собой ряд чисел [1,0, 0,9, …, 0,0].

Следующий ключевой момент — это нарезка (слайсинг):

for (i in 0 until slices) {
    translate(
        left = if (Random.nextInt(5) < step)
            Random.nextInt(-20..20).toFloat() * intensity
        else
            0f,
    ) {
        scale(
            scaleY = 1f,
            scaleX = if (Random.nextInt(10) < step)
                1f + (1f * Random.nextFloat() * intensity)
            else
                1f,
        ) {
            clipRect(
                top = (i / slices.toFloat()) * size.height,
                bottom = (((i + 1) / slices.toFloat()) * size.height) + 1f,
            ) {
                layer {
                    drawLayer(graphicsLayer)
                    if (Random.nextInt(5, 30) < step) {
                        drawRect(
                            color = glitchColors.random(),
                            blendMode = BlendMode.SrcAtop,
                        )
                    }
                }
            }
        }
    }
}

Для каждого слайса применяются следующие преобразования:

1. Сдвиг

На шагах с 10 по 5 каждый фрагмент перемещается на случайное количество пикселей в диапазоне от -20 до 20. Обратите внимание, что с каждым шагом этот диапазон уменьшается, поскольку он умножается на интенсивность.

На шагах с 4 по 0 происходит то же самое, но это не гарантируется для каждого фрагмента, некоторые фрагменты не будут перемещены.

Эффекты с GPU-ускорением: глитч в масштабе

2. Горизонтальное масштабирование

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

Эффекты с GPU-ускорением: глитч в масштабе

3. Цветные полосы

На шагах с 10 по 5 рисуем на каждом срезе полосу случайного цвета с начальной вероятностью 0.2, уменьшающейся до 0 к шагу 5.

После шага 5 полос больше нет.

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

Интенсивности шейдера должно быть достаточно для управления анимацией без использования шагов. В коде Kotlin я по-прежнему буду использовать шаги + интенсивность просто для максимально точного воспроизведения анимации и для будущих измерений производительности.

Смена ментальной модели: шейдеры выполняются попиксельно, НО анимация применяет те же преобразования к группам пикселей — в данном случае к слайсам. Чтобы воспроизвести это в шейдере, нам нужно выполнить точно такие же вычисления для всего слайса. Помните о сходстве с чистыми функциями? Здесь это очень удобно, потому что для получения тех же результатов нам просто нужно применить те же аргументы!

Группа пикселей, которые подвергаются одинаковым преобразованиям, называется слайсом (срезом):

uniform shader image;
uniform float2 imageSize; // Shader area size in pixels
uniform float intensity;
uniform int slices;

// fragCoord — pixel coordinates
half4 main(float2 fragCoord) {
    // Create horizontal slices
    float sliceHeight = imageSize.y / float(slices); // Height of each slice in pixels
    float sliceY = floor(fragCoord.y / sliceHeight) * sliceHeight; // Start coordinates for each slice
    
    // ...
}

Давайте пойдем шаг за шагом и начнем с перевода.

Перевод

Шаги 10..5 эквивалентны интенсивности 1.0..0.5 с шагом 0.1. Поэтому для сдвига срезов будем исходить из этого:

// Simple random functions
float random(float seed) {
  return fract(sin(seed) * 100000.0);
}

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

// Determine how much this slice should be displaced
float displace(float sliceY, float intensity) {
    float rnd = random(float2(sliceY, intensity));
  
    float shouldDisplace;
    if (intensity < 0.5 && intensity > rnd * 0.4) {
        shouldDisplace = 0.0;
    } else {
        shouldDisplace = 1.0;
    }

    return (rnd - 0.5) * 40.0 * intensity * shouldDisplace
}

Что здесь происходит? Во-первых, случайность. Поскольку в шейдерах нет случайности, существуют широко используемые функции, имитирующие рандомность. Обе функции возвращают число с плавающей точкой в ​​диапазоне от 0 (включительно) до 1 (не включая). В данном случае «случайное» значение уникально для каждой комбинации слайс-кадр. Равномерная интенсивность одинакова для всех вызовов (= выходных пикселей) каждого кадра, координаты слайса также одинаковы для одной и той же группы пикселей. В сочетании с начальной координатой слайса это значение различается для каждого слайса в каждом кадре.

Далее, если интенсивность превышает 0.5, коэффициент shouldDisplace мгновенно устанавливается равным 1.0, что означает необходимость смещения слайса. В противном случае, intensity > rnd * 0.4 приводит к снижению вероятности выполнения смещения, аналогично Random.nextInt(5) < step в исходной реализации.

Последняя строка — это просто простая арифметика. Здесь я преобразую псевдослучайное значение 0..1 в -20..20, умножая на интенсивность, как в исходной реализации, и применяя коэффициент, если смещение вообще имеет место.

Эффекты с GPU-ускорением: глитч в масштабе

Масштабирование

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

float2 scale(float2 coord, float yMin, float yMax, float screenWidth, float intensity) {
   float rnd = random(float2(yMin, intensity));

   if (coord.y >= yMin && coord.y <= yMax && rnd < intensity) {
       float centerX = screenWidth * 0.5;
      
       float localX = coord.x - centerX;
       float scaleFactor = 1f + (intensity * rnd);
       localX /= scaleFactor;
      
       float scaledX = localX + centerX;
      
       return float2(scaledX, coord.y);
   }
  
   return coord;
}

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

Опять же, случайные значения уникальны для каждого среза в каждом кадре, что означает, что каждый пиксель в конкретном срезе получает одинаковое значение. Тогда rnd < intensity приводит к снижению вероятности, аналогично Random.nextInt(10) < step.

Эффекты с GPU-ускорением: глитч в масштабе

Цветные полосы

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

float rnd = random(float2(intensity, sliceY));
if ((rnd * 2.5 + 0.5) < intensity) {
   if (rnd > 0.67) {
       return yellow;
   } else if (rnd > 0.33) {
       return red;
   } else {
       return cyan;
   }
} else {
   float2 scaled = scale(displaced, sliceY, sliceY + sliceHeight, imageSize.y, intensity);
   return image.eval(scaled);
}

После того, как все собрано, вот результат:

Эффекты с GPU-ускорением: глитч в масштабе

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

Эффекты с GPU-ускорением: глитч в масштабе

После применения надлежащей случайности все это очень точно воспроизводит исходное поведение:

Полный код опубликован здесь.

Производительность

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

Эффекты с GPU-ускорением: глитч в масштабе

Шейдеры слева, Compose справа

На первый взгляд графики выглядят похожими, но есть одна загвоздка: текущая реализация неявно ограничивает частоту кадров. Анимация отсчитывает значения с плавающей точкой от 10 до 0, но состояние обновляется округлёнными до целых. Это означает, что за 500 мс анимации приходится всего 11 кадров. Это очень удобно для шейдера глитча, поскольку более низкая частота кадров также способствует его восприятию. Чтобы снять это ограничение, нам нужно просто изменить тип шага с Int на Float и использовать значение анимации без округления.

Эффекты с GPU-ускорением: глитч в масштабе

Эффекты с GPU-ускорением: глитч в масштабе

Шейдеры слева, Compose справа

После снятия ограничения по количеству кадров разница в производительности уже заметна. Давайте проведём стресс-тест. Что будет, если анимация будет применена ко всему списку? Или количество фрагментов увеличится? Посмотрим!

Эффекты с GPU-ускорением: глитч в масштабе

Шейдеры слева, Compose справа — без ограничения по кадрам, применяется ко всему списку, 100 слайсов

Заключение

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

С другой стороны, шейдеры обеспечивают значительно более производительный и стабильный рендеринг. В данном случае неважно, сколько слайсов используется — с точки зрения вычислений нет разницы, 20 или 500, в то время как чистая версия Compose очень чувствительна к этому вопросу и линейно растёт с увеличением количества слайсов.

Кроме того, поскольку шейдер AGSL — это просто текст, компилируемый ad-hoc во время выполнения, технически возможно обновлять эти анимации из бэкенда.

Есть также одно большое НО: шейдеры доступны с Android 13, поэтому, согласно распределению версий в ОС Android Studio, только половина устройств сможет поддерживать этот подход. Конечно, со временем ситуация изменится, и я надеюсь, что мы сможем в полной мере раскрыть потенциал графического программирования с помощью шейдеров!

Эффекты с GPU-ускорением: глитч в масштабе

Истотчник

Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.
Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: