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

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

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

Для написания фрагментных шейдеров 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? Их можно просто отнять?

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

То есть да, [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. Эта статья их не покрывает, но даёт достаточно знаний, чтобы исследовать их самому.

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

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

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

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

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

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

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

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

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

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

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