Как создать анимированные шейдеры в Jetpack Compose
Jetpack Compose — молодой, но бурно развивающийся фреймворк для разработки под Android, который обладает множеством не всегда очевидных фичей. Сегодня я хотел бы описать одну из таких встроенных возможностей: речь идет об использовании OpenGL-шейдеров. Они позволяют делать красивые анимированные интерфейсы, как на картинке ниже.
Я написал это приложение не просто на Jetpack Compose, а на Compose Multiplatform, поэтому вместе с мобильной версией у нас должна получиться аналогичная десктопная:
В качестве основы дизайна для приложения возьмем первый попавшийся проект с 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 скажется на всем интерфейсе приложения.
Я поигрался со своим пет-проектом и запустил еще шейдер с горой Фудзи в стиле киберпанк. Возможно, вам захочется поставить на фон что-то свое, не ограничивайте полет фантазии!
UPD: в приложении появилась возможность выбирать один шейдер из трех прямо из интерфейса: