Jetpack Compose — молодой, но бурно развивающийся фреймворк для разработки под Android, который обладает множеством не всегда очевидных фичей. Сегодня я хотел бы описать одну из таких встроенных возможностей: речь идет об использовании OpenGL-шейдеров. Они позволяют делать красивые анимированные интерфейсы, как на картинке ниже.
image

Я написал это приложение не просто на Jetpack Compose, а на Compose Multiplatform, поэтому вместе с мобильной версией у нас должна получиться аналогичная десктопная:

image

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

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

Чтобы функция работала, нужен а) шейдер на OpenGL и б) код на Kotlin, который запускает этот шейдер. Начнем с первого. Я взял уже имеющийся шейдер, классическую старую как мир анимацию с облаками, которая даже на моем Pixel 7 выдает 90 fps. Чтобы было удобно работать и линтер Intellij Idea подсветил код на другом языке в .kt файле, добавим аннотацию @Language перед строкой:

@Language("GLSL")
const val compositeSksl = """
               // Параметры шейдера
                uniform float3 iResolution;      // Viewport resolution (pixels)
                uniform float  iTime;            // Shader playback time (s)
                // Тело шейдера
                ...
            """

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


@RequiresApi(Build.VERSION_CODES.TIRAMISU)
actual fun Modifier.shaderEffect(): Modifier = composed {
    val time by produceState(0f) {
        while (true) {
            withInfiniteAnimationFrameMillis {
                value = it / 1000f
            }
        }
    }
    Modifier.drawWithCache {
        val shader = RuntimeShader(compositeSksl)
        val shaderBrush = ShaderBrush(shader)
        shader.setFloatUniform("iResolution", size.width, size.height)
        shader.setFloatUniform("iTime", time)
        onDrawBehind {
            drawRect(shaderBrush)
        }
    }
}

Что здесь происходит? produceStateOf запускает отдельную корутину (side-effect), в которой будет отсчитываться текущее время шейдера. withInfiniteAnimationFrameMillis запускает бесконечную покадровую анимацию, результат этой анимации записывается в produceState.

drawWithCache позволяет не только рисовать что-либо, но и кэшировать значения переменных внутри функции. Это дает нам возможность оптимизировать выделение памяти под наши объекты. Параметры в шейдер передаем при помощи setFloatUniform, причем внимательно следим за типами данных: в таком интеропе, к сожалению, нет compile-time проверки, что в float передан один ключ, а в float2 — два ключа.

Что касается десктопной версии, она будет отличаться, но незначительно. Код самого шейдера останется тем же, но за рендер будет отвечать десктопная обертка Skiko (Skia for Kotlin):


actual fun Modifier.shaderEffect(): Modifier = composed {
    val time by produceState(0f) {
        while (true) {
            withInfiniteAnimationFrameMillis {
                value = it / 1000f
            }
        }
    }
    Modifier.drawWithCache {
        val effect = RuntimeEffect.makeForShader(compositeSksl)
        val compositeShaderBuilder = RuntimeShaderBuilder(effect)
        compositeShaderBuilder.uniform(
            name = "iResolution",
            value1 = size.width,
            value2 = size.height
        )
        compositeShaderBuilder.uniform(
            "iTime",
            time
        )
        val shaderBrush = ShaderBrush(compositeShaderBuilder.makeShader())
        onDrawBehind {
            drawRect(shaderBrush)
        }
    }
}

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

Я поигрался со своим пет-проектом и запустил еще шейдер с горой Фудзи в стиле киберпанк. Возможно, вам захочется поставить на фон что-то свое, не ограничивайте полет фантазии!

image



UPD: в приложении появилась возможность выбирать один шейдер из трех прямо из интерфейса:

image

Комментарии (10)


  1. Rusrst
    19.05.2023 14:20

    Шейдеры только в 13 Андроиде завезли, ниже их нет, так что конечно красиво, но ветвления логики что-то не заметно, кто соберёт на устройство ниже, поймает краш.

    А у вас все хорошо работает, glsl вместо agls подхватился без проблем?


    1. glider_skobb Автор
      19.05.2023 14:20

      Да, работает хорошо, запускал как в эмуляторе, так и на физическом устройстве.


  1. quaer
    19.05.2023 14:20

    Я написал это приложение не просто на Jetpack Compose, а на Compose Multiplatform

    Совсем запутали... Как понять что есть что?


    1. glider_skobb Автор
      19.05.2023 14:20

      Jetpack Compose - фреймворк для разработки под Андроид от Google. Вскоре после его релиза Jetbrains выпустили на его основе Compose Multiplatform - фреймворк, позволяющий запускать интерфейсы Jetpack Compose дополнительно в десктопных приложениях, iOS и в браузере. Управление состоянием, StateFlow, рендерер Skia те же. Подпробнее можно почитать тут: https://github.com/JetBrains/compose-multiplatform и в моей статье: https://habr.com/ru/companies/timeweb/articles/734818/


      1. Rusrst
        19.05.2023 14:20
        -1

        Они даже пару слов в язык добавили, все ради него.


        1. glider_skobb Автор
          19.05.2023 14:20

          Если вы про expect и actual - они доступны только при наличии KCP kotlin("multiplatform"). Любой желающий может добавить в свой проект на котлине новые ключевые слова, если напишет собственный плагин компиляции.


          1. Rusrst
            19.05.2023 14:20

            Да ладно, а чего же они аж в compose для android лежат без всяких плагинов? А давно у нас модификаторы языка без плагинов не собираются?

            https://kotlinlang.org/docs/keyword-reference.html#modifier-keywords


  1. nikita_dol
    19.05.2023 14:20
    +1

    Я так понимаю, что от Modifier.drawWithCache нету смысла, так как происходит постоянное изменение времени, а значит, что шейдеры пересоздаются на каждую перерисовку


    1. glider_skobb Автор
      19.05.2023 14:20

      Когда люди пробуют рисовать такое без кэширования, fps дропается до 5 или ниже:

      https://slack-chats.kotlinlang.org/t/10007219/has-anyone-tried-using-runtime-shaders-on-a-jetpack-compose-


  1. nikita_dol
    19.05.2023 14:20
    +1

    (Промахнулся веткой, ответ на этот комментарий)

    Согласно документации, drawWithCache вызывает лямбду когда изменился размер рисуемой области и/или состояние, которое читается из лямбды

    В вашем случае, это сработает только между кадрами, если кадры будут рисоваться быстрее, чем вы изменяете переменную time

    Можете print в консоль сделать и убедится, что у вас постоянно создаются новые шейдеры