Как создать анимированные шейдеры в Jetpack Compose

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

mxuanbovcusqgmqdgugvpnql8vq.jpeg

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

image

© Habrahabr.ru