Введение в шейдеры и AGSL

Привет, котлинята! В этой статье мы поговорим про шейдеры и графическое программирование.

Так что же такое этот ваш шейдер?

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

Для написания фрагментных шейдеров Android (вообще Skia, но об этом позже) предоставляет несколько интересных API, которые очень хорошо работают с Compose. Важно: шейдеры AGSL доступны только с Android 13 и выше. Compose Multiplatform таких ограничений на остальных платформах не имеет.

Будет здорово, если вы откроете новый проект в Android Studio и будете писать его вместе со мной.

Давайте напишем очень простой пример, а потом попробуем написать логарифмичную спираль!

Закрутимся?

Начнём с базы. Прежде чем писать шейдер, нам нужно место, где мы его сможем применить.

@Composable
fun Spiral(modifier: Modifier = Modifier) {
Box(Modifier.size(300.dp)
.drawWithCache {
// SPIRAL - String, который содержит шейдер
val shader = RuntimeShader(SPIRAL)
val shaderBrush = ShaderBrush(shader) // передаём шейдер в Brush
// говорим шейдеру, где находится центр спирали
shader.setFloatUniform("u_center", size.width / 2, size.height / 2)
onDrawBehind {
drawRect(shaderBrush) // рисуем!
}
}
then modifier) {
// Box может быть пустым. Если бы здесь что-то было, оно бы
// отображалось на фоне шейдера.
}
}

А теперь простенький шейдер.

@Language("AGSL")
const val SPIRAL = """
uniform vec2 u_center;

vec4 main(in vec2 fragCoord) {
return vec4(vec3(1, 0, 1), 1.0);
}
"""

Запустим...

Уфф, розовый! Но зато легко объяснить, что здесь произошло.

Присмотритесь к коду шейдера:

return vec4(vec3(1, 0, 1), 1.0);

Задача любого фрагментного шейдера - вернуть значение в формате RGBA. То есть, вектор из четырёх значений - красный (R), зелёный (G), синий (B), прозрачность/альфа (A), все от 0 до 1. Векторы в принципе можно воспринимать как Kotlin List<Float>, только без огромной стандартной библиотеки за их спиной. Она нам и не нужна - простой тригонометрии достаточно для множества вещей.

Так а что такое этот uniform? in vec2 fragCoord?

Хороший вопрос! Юниформы - это параметры, которые шейдер ожидает получить извне. Со стороны игрового движка, UI-фреймворка, приложения - неважно. Это способ их настраивать. А fragCoord - координаты нашего пикселя. Есть идея, чтобы показать, как с ними работать, но надо добавить ещё один uniform:

shader.setFloatUniform("u_center", size.width / 2, size.height / 2)
onDrawBehind {
// новый uniform!
shader.setFloatUniform("u_radius", min(size.width / 2, size.height / 2));
drawRect(shaderBrush)
}

uniform vec2 u_center;
uniform float u_radius;

vec4 main(in vec2 fragCoord) {
float distance_from_center = length(fragCoord - u_center);
float blue = distance_from_center / u_radius;
return vec4(vec3(1, 0, blue), 1.0);
}

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

Погоди, а что тут за fragCoord - u_center? Разве они оба не vec2? Их можно просто отнять?

Да, и умножать, и добавлять... Вспомним немного высшей математики:

a+b= ​a1​a2​⋮an​​ ​+ ​b1​b2​⋮bn​​ ​= ​a1​+b1​a2​+b2​⋮an​+bn​​ ​

То есть да, [1, 5] + [2, 2] = [1 + 2, 5 + 2] = [3, 7]. Работает это только тогда, когда векторы одинаковой длины. Очень удобно!

Полагаю, уже можно писать спираль.

uniform vec2 u_center;
uniform float u_arms;

vec4 main(in vec2 fragCoord) {
float dist = length(fragCoord - u_center); // расстояние от центра
float angle = atan(fragCoord.y - u_center.y, fragCoord.x - u_center.x); // угол относительно ординаты x

// Спиралька!
float logDist = log(dist + 0.001); // +0.001 избегает вызова log(0), что может обернуться катастрофой
float tightness = 15.0; // мог быть и uniform параметр, но мне лень :)
float spiral = cos(logDist * tightness + angle * u_arms); // значения идут от -1 до 1

// Более быстрый способ сказать "если меньше 0, то 0, иначе 1"
float spiralSign = sign(spiral);
float pattern = max(0, 1 * spiralSign);

return vec4(vec3(pattern), 1.0);
}

Много математики! Моя не понимать!

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

Так, давайте добавим необходимые юниформы...

shader.setFloatUniform("u_center", size.width / 2, size.height / 2)
onDrawBehind {
shader.setFloatUniform("u_arms", 20.0f)
drawRect(shaderBrush)
}

Запускаем...

Красота! Двадцатирукая спираль. Пиксельноватая, однако.

Раз мы можем передавать в юниформы практически любые числа, прям как в функцию, почему бы туда не передать время и заставить спираль крутиться?

Почему бы и нет? Просто добавим два значения - скорость и текущее время в миллисекундах - в формулу:

val time by produceState(0f) {
while (true) {
withInfiniteAnimationFrameMillis {
value = it / 1000f
}
}
}
Box(Modifier.size(300.dp)
.drawWithCache {
// ...
onDrawBehind {
// ...
shader.setFloatUniform("u_time", time)
shader.setFloatUniform("u_speed", 5.0f)
drawRect(shaderBrush)
}
}
then modifier) {

}

// ...
uniform float u_time;
uniform float u_speed;

vec4 main(in vec2 fragCoord) {
// ...

// внимание на `- u_time * u_speed`
// если - заменить на +, спираль пойдёт против часовой стрелки
float spiral = cos(logDist * tightness + angle * u_arms - u_time * u_speed);

// ...
}

Чёрно-белая крутящаяся спираль - это классика гипнотического жанра, но это как-то скучно. Почему бы её не покрасить?

Хорошо. У нас есть два способа - прикрутить сверху градиент через mix и ещё две vec4 юниформы color1 и color2 ИЛИ передать шейдер - на этот раз встроенный в Android/Skia - в наш шейдер и в конце воспользоваться уже им. Оба играют на том, что значение pattern находится в диапазоне от 0 до 1. "Просто mix" будет несколько быстрее, а передача шейдера в шейдер позволяет компоновать их, прям как функции!

(Если подушнить, то шейдеры - это и есть функции.)

val gradient = remember {
LinearGradient(
/* x1, y1, x2, y2 = */ 0f, 0f, 1f, 0f,
// цвета - взял цвета, напоминающие об Amper
intArrayOf(Color(0xFF00BCD4).toArgb(), Color(0xFF009688).toArgb()),
floatArrayOf(0f, 1f), // где расположены цвета на градиенте от 0 до 1,
// одно местоположение на цвет
Shader.TileMode.CLAMP // на всякий случай
)
}
Box(Modifier.size(300.dp)
.drawWithCache {
// ...
onDrawBehind {
// ...
shader.setInputShader("u_colorRamp", gradient)
drawRect(shaderBrush)
}
}
then modifier) {

}

// ...
uniform shader u_colorRamp;

vec4 main(in vec2 fragCoord) {
// ...

// заменяем прошлый return!
// vec2(x, y) - так вызывается конкретно LinearGradient
return u_colorRamp.eval(vec2(pattern, pattern));
}

А вот и оно!

Представьте, если бы шейдерами можно было менять положение уже отрендеренных пикселей. Например, так делает Telegram Android с эффектами Liquid Glass. Хорошо это или нет - судите сами, но возможность присутствует. Главное - то, что всё, что написано на AGSL, гарантированно работает на отдельном железе, специально заточенным под графику.

На YouTube-канале Android Developers есть простая демонстрация (отрывок видео: 1 минута) ровно этой возможности. Если хотите узнать больше, посмотрите или поиграйтесь с API RenderEffects.

Окей, круто, я убеждён. Шейдеры - это будущее, надо построить на них компанию с оценкой в несколько-сот миллиардов и вставлять их везде, где только руки дотянутся.

Но как я использую шейдеры со, скажем, кнопкой?

Достаточно просто сделать Brush из шейдера, объявить кнопку прозрачной, clip'нуть её с ButtonDefaults.shape, и выставить colors.background на Color.Transparent:

@Composable
fun HypnotizeButton(modifier: Modifier = Modifier) {
val time by produceState(0f) {
while (true) {
withInfiniteAnimationFrameMillis {
value = it / 1000f
}
}
}
val gradient = remember {
LinearGradient(
0f, 0f, 1f, 0f,
intArrayOf(Color(0xFF7F52FF).toArgb(), Color(0xFFE24462).toArgb()),
floatArrayOf(0f, 1f),
Shader.TileMode.CLAMP
)
}
val runtimeShader = remember { RuntimeShader(SPIRAL) }
val shaderBrush = remember(time, runtimeShader) {
object : ShaderBrush() {
override fun createShader(size: Size): androidx.compose.ui.graphics.Shader {
runtimeShader.setFloatUniform("u_center", size.width / 2, size.height / 2)
runtimeShader.setFloatUniform("u_time", time)
runtimeShader.setFloatUniform("u_speed", 5.0f)
runtimeShader.setFloatUniform("u_arms", 20.0f)
runtimeShader.setInputShader("u_colorRamp", gradient)
return runtimeShader
}

}
}

val context = LocalContext.current
Button(
onClick = {
Toast.makeText(context, "Hypnotized", Toast.LENGTH_SHORT).show()
},
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent,
),
modifier = Modifier
.clip(ButtonDefaults.shape)
.background(shaderBrush)
then modifier
) {
Text("Hypnotize me")
}
}

Ровно этим же Brush'ем можно и текст красить:

@Composable
fun HypnoText(modifier: Modifier = Modifier) {
// переменные ниже скопированы из HypnotizeButton
val time by produceState(0f) {
// ...
}
val gradient = remember {
// ...
}
val runtimeShader = remember { /* ... */ }
val shaderBrush = remember(time, runtimeShader) {
// ...
}
Text(
text = "Welcome.",
style = TextStyle(
brush = shaderBrush,
fontWeight = FontWeight.ExtraBold,
fontSize = 36.sp
),
modifier = modifier
)
}

Не круто ли?

P.S. я подкрутил здесь u_arms до 2f - выглядит лучше на узких компонентах.

Это прикольно, конечно, но пока я строил новый пузырь на основе AGSL, я попал в проблему - как мне это использовать с Compose Multiplatform?

Через expect/actual!

Весь "AGSL" код на самом деле субсет SkSL, который работает со Skia. На основе библиотеки Skiko (Skia for Kotlin) и построен Compose Multiplatform. Чтобы шейдеры заработали, достаточно заменить пару импортов:

// iosMain, desktopMain, wasmJsMain
import org.jetbrains.skia.RuntimeEffect
import org.jetbrains.skia.RuntimeShaderBuilder

// androidMain
import android.graphics.RuntimeShader

Некоторые API Skia незначительно отличаются от Android. API для постройки шейдеров пусть и семантически очень похож, но функции называются несколько иначе. Для полной картины рекомендую почитать эту статью на Medium.

Ну что же, подытожим!

Мы написали шейдер, который генерирует спираль математическим путём

Задали параметры через uniforms

Заставили спираль крутиться, передавая state как uniform

Покрасили спираль в цвета Amper

"Покрасили" текст и кнопку этой спиралью

Внизу прилагается список ресурсов для тех, кто хотел бы узнать больше.

На связи был Kotlinism. Не забудьте поделиться статьёй и задать интересующие вопросы в комментариях, а также заглянуть в Telegram-канал , если вы ещё не там. Пока!

Дополнительные ресурсы

Документация по AGSL

Shadertoy.com

The Book of Shaders - прелестная, пусть и незавершённая.

Читайте ещё и то, на что книга ссылается - особенно математику

Статья на Хабре про работу пайплайна AGSL под капотом.

Кроличьи норы

Эти вопросы интересно изучить на досуге и сильно углублят понимание AGSL. Эта статья их не покрывает, но даёт достаточно знаний, чтобы исследовать их самому.

Почему if такой дорогой в шейдерах, что его практически нельзя использовать? (Спойлер: это сильно связано с архитектурой железа)

Какие есть библиотеки и платформы для графического программирования в общем?

Как насчёт рекреационного программирования?

Почему именно AGSL ограничен Android 13 и выше? Flutter и SkSL там же работают и на Android 12 и ниже, не так ли ведь?

А как было с шейдерами раньше? (Наводки: RenderScript, OpenGL capability levels)

OpenGL на самом деле медленный?

Как создать игру на AGSL? Годен ли Compose как игровой движок?

Что мешает сделать 3D игру на Compose + AGSL?

Как контролировать толщину спиралей, которые мы написали?

Что дало бы более плавную, не-пиксельную спираль?

Тут уже весело! Можно просто изменить одну строку в AGSL коде и использовать smoothstep, а можно наложить какой-нибудь известный антиалиасинг-алгоритм поверх нашего шейдера. FXAA, MLAA, SSAA?