В последние годы интерфейсы приложений становятся все более интерактивными. Простого эфф��кта нажатия на кнопку уже недостаточно - пользователи ждут живых анимаций и визуальной глубины. Но создание таких эффектов традиционно требовало от разработчиков значительных усилий.
Представь: тебе нужно «поколдовать» над пикселями прямо в UI - добавить живой градиент, искажение картинки под пальцем, стеклянный блеск карточке и тому подобные эффекты. Раньше для этого приходилось прибегать к «тяжеловесам» таким как OpenGL/Vulkan, либо мучить CPU постобработкой битмапов. AGSL (Android Graphics Shading Language) решает это элегантнее: это язык фрагментных шейдеров, встроенный в сам графический стек Android, так что эффекты применяются прямо на уровне отрисовки интерфейса.
Чтобы картинка сложилась, быстро разберём базовые термины (коротко и по делу):
Skia - это кросс-платформенная 2D-графическая библиотека (движок), на которой строится отрисовка Android. Через неё проходится всё: текст, векторы, растры, эффекты. Думаешь «Canvas» - под капотом упираешься в Skia.
RenderThread - выделенный поток отрисовки Android, который старается держать анимации на 60/120 FPS и не блокировать UI-поток. Любые тяжёлые GPU-штуки лучше делать так, чтобы RenderThread, как господин ПЖ из х/ф “Кин-дза-дза!”, «радовался».
GLSL - классический «язык шейдеров» из мира OpenGL. AGSL на него похож по духу (это тоже про формулы цвета для каждого пикселя), но адаптирован под Skia/Android и UI-контекст.
Пайплайн - конвейер обработки кадра: твой UI → слои/эффекты → рендер → экран. AGSL встраивается в этот конвейер как ещё одно звено: «взял пиксель → посчитал формулу → вернул цвет».
Uniform - параметр шейдера, который задаёшь из Kotlin (числа, вектора и т. п.). Это «ручки управления»: время, координата касания, интенсивность эффекта. Меняешь uniform - меняется картинка, без перекомпиляции шейдера.
Почему это важно именно для прикладного разработчика? Потому что сегодня интерфейсы - это не только кнопки и списки, но и микровзаимодействия, мягкие переходы, живые материалы (стекло, шум, зерно, и т.п.), акценты, которые формируют ощущение качества. Нам нужны маленькие, точные и быстрые эффекты: волна при тапе, живой градиент фона, шумок, лёгкое искажение на картинке, стильная заливка текста. Раньше за такие эффекты приходилось расплачиваться OpenGL-слоями, цепочками Surface и собственными рендер-пайплайнами. Теперь достаточно одной строки AGSL и пары uniform-ов. В AGSL это всё - нативная часть UI: минимум обвязки, максимум контроля.
Зачем появился AGSL
Раньше выбор был неприятный: либо писать собственный GL/Vulkan-рендер ради одной «фишечки», либо довольствоваться фиксированными эффектами. AGSL появился как золотая середина:
Произвольные пиксельные формулы прямо в UI-слое (без подъёма 3D-сцены).
Интеграция со стандартными инструментами Android (Canvas/Compose/RenderEffect/ShaderBrush).
Гибкая параметризация через uniform-ы (время, жесты, слайдеры).
Итог - ты управляешь пикселями на месте, где им самое место: в конвейере отрисовки интерфейса.
Краткая история (без занудства)
Когда Android эволюционировал в сторону более зрелого, выразительного и материал-ориентированного дизайна, стало ясно: стандартных визуальных эффектов системного UI больше не хватает. Мир требовал мягких подсветок, динамических градиентов, стеклянных панелей, размытия, шума, эффектов глубины - всего того, что создаёт ощущение «живого» интерфейса.
Но инструменты были… тяжеловаты:
подключать OpenGL ES ради одного динамического градиента - сомнительное удовольствие;
делать чувствительные к состоянию UI эффекты через SurfaceView/TextureView - сложно, громоздко, и всегда «особняком» от остального рендера Android;
кастомные пайплайны с FBO, шейдерами, синхронизацией потоков, собственным рендер-циклом - это уже уровень игровых движков, а не прикладных приложений.
Графическая команда Android давно продвигала Skia - низкоуровневый движок, на котором рендерится весь UI. Постепенно в него начали приносить возможности, которые раньше жили только в OpenGL/GLSL. Шейдеры на языке SkSL уже существовали внутри Skia (в Chrome, Flutter, графических инструментах Google), но в Android их напрямую использовать было нельзя.
И вот в Android 13 (API 33) появилось то, чего разработчики ждали много лет:
RuntimeShader - пропуск в мир нативных шейдеров внутри UI
Это механизм, который:
компилирует AGSL прямо на устройстве;
встраивает шейдер в GPU-пайплайн самого Android, а не в отдельный OpenGL-контекст;
позволяет применять эффекты к Paint, градиентам, картинкам, тексту, а не только к отдельным Surface;
работает потоко-безопасно, дружит с рендер-циклом UI и имеет минимальную обвязку.
AGSL (Android Graphics Shading Language) вырос из SkSL и по духу напоминает GLSL-фрагментники, но адаптирован под 2D-графику и постобработку, а не под полноценные 3D-сцены. Это значит: нет матриц моделей/видов/проекций, есть только то, что нужно для эффекта: координаты, цвета, UV, uniforms, шейдер всегда пишется как функция «возьми пиксель → измени → верни».
Это шейдеры, которые наконец-то стали частью UI-платформы Android, а не отдельной, постоянно ломающейся подсистемой. И это момент, когда графические эффекты становятся доступными не только геймдеву и фреймворк-разработчикам, но и обычным прикладным разработчикам.
Где AGSL хорош, а где он не про это
AGSL лучше всего проявляет себя там, где требуется UI-постобработка и процедурные заливки - любые эффекты, которые работают на уровне пикселей и накладываются поверх конкретного виджета или слоя. Он особенно удобен для интерактивных сценариев: uniform-параметры позволяют привязывать эффект к времени, жестам или значениям интерфейсных контролов, создавая живые отклики.
При этом AGSL исполняется прямо на GPU, минуя лишние походы через CPU, что даёт ощутимый прирост производительности. А благодаря интеграции с RuntimeShader, RenderEffect и ShaderBrush, шейдеры естественно вписываются в современные Android-инструменты и особенно хорошо работают в Compose.
Однако важно помнить про ограничения. AGSL - это только фрагментный шейдер, без вершин, геометрии и всего, что связано с полноценным 3D-пайплайном. Он доступен только начиная с API 33 (Android 13), поэтому для устройств ниже придётся продумать деградационный путь или отключать эффекты. Вводные данные шейдера тоже ограничены: можно использовать входной shader (например, слой или изображение), но это не те «текстурные юниты», к которым привык GLSL.
Технически AGSL допускает ветвления и циклы, но чрезмерное их использование быстро ударит по FPS. И наконец, точность работы с координатами полностью ложится на разработчика: правильная нормализация, учёт плотности пикселей, работа с краями и грамотный клэмпинг - всё это нужно продумывать вручную.
А если устройство с API < 33?
Поскольку пользоваться прелестями AGSL можно только начиная с API 33, для старых платформ остаются три разумных подхода. Самый простой - это грейсфул-деградация: определить ядро UX и просто отключить AGSL-эффекты на старых устройствах, оставив статический фон или более плоский стиль. Такой подход честный, безопасный и обычно самый предсказуемый. Второй вариант - приблизительный аналог: для простых эффектов вроде градиента или лёгкой тени вполне можно подобрать стандартные средства Android и Compose - GradientBrush, Blur, Shadow или другие готовые визуальные приемы. И наконец, самый тяжеловесный путь - отдельный рендер-путь через OpenGL ES, если нужный эффект действительно критичен. Это сильно дороже в поддержке и в большинстве UI-сценариев не оправдано, но как крайняя опция существует.
Главное - не ломать UX на старых девайсах: пусть будет проще, но стабильно. В сухом остатке AGSL - это компактная суперсила для UI. Немного математики, пара uniform-ов - и интерфейс начинает «дышать», без тяжёлой артиллерии и лишней обвязки.
Как AGSL вплетается в Android и Compose
Чтобы понять AGSL, полезно увидеть всю цепочку от самого нижнего слоя до того момента, когда пиксель оказывается на экране. Не "в общем" и не "примерно", а так, как это реально работает под капотом Android.
1. Choreographer - метроном UI
Каждый кадр в Android начинается не с отрисовки, а с сигнала от Choreographer.
Это тот самый "метроном", который синхронизируется с VSync и говорит всей системе:
"Настало время подготовить новый кадр."
В этот момент:
Compose получает doFrame()
View-система получает doFrame()
анимации обновляют своё состояние
выполняются Layout, Measure, Draw
К этому моменту интерфейс уже логически готов, но ещё нигде не нарисован.
2. RenderThread - рабочая лошадка рендера
Дальше включается RenderThread - отдельный поток, который преобразует логическое дерево UI в набор команд для Skia. Можно думать о нём так:
UI уже решил что нарисовать, RenderThread решает как именно это отправить на GPU:
собирает слои, композиции, alpha, clip-ы
применяет визуальные эффекты
подготавливает командный буфер Skia
И только после этого управление передаётся движку рисования.
3. Skia - главный художник Android
Skia - это тот уровень, который на самом деле "реализует" пиксель.
Он принимает команды вроде "нарисуй прямоугольник", "залей градиентом", "положи текстуру" и преобразует всё это в GPU-инструкции.
И вот именно здесь появляется AGSL.
4. Где находится AGSL
AGSL встроен внутри Skia и работает на том же уровне, где Skia рассчитывает каждый пиксель. Это не отдельный движок и не GL-контекст - это часть основного рендер-пайплайна.
Упрощённая схема:
Choreographer → Compose/View → RenderThread → Skia → AGSL → GPU → экран
AGSL - это маленький, гибкий фильтр, который срабатывает перед выводом пикселя.
Он получает:
координату fragCoord
опциональное входное изображение (если эффект - постобработка)
uniform-ы (параметры, которые ты задаёшь из Kotlin)
И возвращает ровно один результат: цвет пикселя.
Ни больше, ни меньше. Можно думать об AGSL как о "точке вмешательства" в последнюю фазу рендеринга.
5. Почему AGSL вообще возможен
Потому что Android 13 впервые открыл доступ к механизму RuntimeShader - шлюзу, который позволяет передавать текст AGSL прямо в Skia.
Skia компилирует шейдер под конкретный GPU на устройстве, RenderThread передаёт uniform-ы, а дальше всё исполняется на GPU параллельно.
Без OpenGL.
Без ручного EGL.
Без собственных рендер-циклов.
Без построения текстур вручную.
6. Синтаксис AGSL - компактный GLSL для UI
AGSL выглядит как GLSL, но очищен от всего, что не нужно UI:
типы: float, float2, float3, half4
операции над векторами
стандартная математика: sin, cos, dot, clamp, smoothstep
одна точка входа:
half4 main(float2 fragCoord)
Это всё. Никаких вершин, матриц, буферов. Только пиксели. Тебе дают координату - ты возвращаешь цвет.
7. Uniform - способ общения Kotlin с GPU
Uniform - это параметры, которые ты задаёшь из Kotlin: время, размеры, касание, интенсивность эффектов.
shader.setFloatUniform("u_time", seconds)
shader.setFloatUniform("u_resolution", width, height)
Uniform общий для всей области, не меняется между пикселями. Именно поэтому он идеален для анимации.
Ну все, хватит теории, пора перейти к самому вкусному - к практике!
Пример 1: Анимированный градиент
Этот пример - мягкий вход в AGSL. Никакой тяжёлой геометрии, только живой фон на синусах.
Шейдер: три синуса и немного математики
private const val ANIMATED_GRADIENT_SHADER = """
uniform float2 u_resolution;
uniform float u_time;
half4 main(float2 fragCoord) {
float2 uv = fragCoord / u_resolution;
float t = u_time;
float r = 0.5 + 0.5 * sin(3.0 * uv.x + t * 0.7);
float g = 0.5 + 0.5 * sin(3.0 * uv.y + t * 1.1);
float b = 0.5 + 0.5 * sin(3.0 * (uv.x + uv.y) + t * 0.9);
return half4(r, g, b, 1.0);
}
"""
AGSL здесь работает как генератор процедурной заливки.
Фреймворк передаёт в main координату пикселя, шейдер нормализует её в диапазон [0..1] и считает три независимых синусоида:
красный зависит от X;
зелёный от Y;
синий - от диагонали;
Каждая волна чуть отличается по частоте и фазе - как три инструмента, которые вместе дают «живой» градиент. u_time - главный дирижёр. Меняется каждый кадр - значит, все синусы начинают «течь» по экрану и в результате получается плавно "дышащий" градиент, который никогда не повторяется строго одинаково.
Compose
Реализация compose функции
@Composable
fun AnimatedAgslBackground(
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit = {}
) {
val runtimeShader = remember {
RuntimeShader(ANIMATED_GRADIENT_SHADER)
}
val paint = remember { Paint() }
var timeSeconds by remember { mutableFloatStateOf(0f) }
LaunchedEffect(Unit) {
while (isActive) {
withFrameNanos { frameTimeNanos ->
timeSeconds = frameTimeNanos / 1_000_000_000f
}
}
}
Box(
modifier = modifier
.drawBehind {
val width = size.width
val height = size.height
runtimeShader.setFloatUniform("u_resolution", width, height)
runtimeShader.setFloatUniform("u_time", timeSeconds)
val frameworkPaint = paint.apply {
shader = runtimeShader
}
drawIntoCanvas { canvas ->
canvas.nativeCanvas.drawRect(
0f,
0f,
width,
height,
frameworkPaint
)
}
}
) {
content()
}
}Это важный мост: withFrameNanos - синхронизация с Choreographer.
LaunchedEffect(Unit) {
while (isActive) {
withFrameNanos { frameTimeNanos ->
timeSeconds = frameTimeNanos / 1_000_000_000f
}
}
То есть каждый кадр мы получаем текущее время → кладём его в uniform → шейдер получает новую фазу → фон движется.
И всё происходит строго в ритме кадровой разметки Android. Каждый новый кадр - новое значение u_time в шейдере. Шейдер не анимируется "сам по себе" - ему просто постоянно подкручивают uniform. Без таймеров, без задержек, без дергания.
Отрисовка: Shader -> Paint -> Canvas
runtimeShader.setFloatUniform("u_resolution", width, height)
runtimeShader.setFloatUniform("u_time", timeSeconds)
Uniform-ы - это «рычаги управления». Мы задаём только два: разрешение шейдера и время, а остальное AGSL сделает сам.
Финальный штрих:
canvas.nativeCanvas.drawRect(0f, 0f, width, height, frameworkPaint)
Самое приятное - снаружи это выглядит как обычная работа с Canvas. Никаких GL-контекстов, текстур, буферов. Rect - это холст. Shader - кисть. И фон... начинает "жить".
На мой взгляд - это полезный стартовый пример, который показывает базовую модель AGSL: есть функция main(fragCoord), мы нормализуем координаты, берём время из uniform, считаем цвет, GPU делает это для каждого пикселя. Compose здесь занимается только анимацией времени и вызовом отрисовки. Всё "тяжёлое" пиксельное мясо живёт в шейдере.
Пора переходить к следующему примеру.
Пример 2: Ripple - интерактивная волна от тапа на экран
Теперь - пример, который показывает настоящую силу AGSL: интерактивность. Это не просто анимация, это эффект, который реагирует на пальцы пользователя.
Шейдер:
private const val RIPPLE_SHADER = """
uniform float2 u_resolution;
uniform float2 u_touch;
uniform float u_time;
uniform float u_active;
half4 main(float2 fragCoord) {
float2 uv = fragCoord / u_resolution;
float2 center = u_touch / u_resolution;
float dist = distance(uv, center);
float speed = 2.0;
float frequency = 20.0;
float maxRadius = 0.5;
float t = u_time * speed;
float wave = 0.5 + 0.5 * cos(frequency * (dist - t));
float radiusMask = smoothstep(maxRadius, 0.0, dist);
float timeMask = smoothstep(0.0, 1.0, 1.0 - t);
float intensity = wave * radiusMask * timeMask * u_active;
float3 baseColor = float3(0.1, 0.12, 0.18);
float3 rippleColor = float3(0.2, 0.6, 1.0) * intensity;
float3 finalColor = baseColor + rippleColor;
return half4(finalColor, 1.0);
}
"""
Реализация основного кода
@Composable
fun RippleAgslBackground(
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit = {}
) {
val runtimeShader = remember { RuntimeShader(RIPPLE_SHADER) }
val paint = remember {
Paint().apply {
shader = runtimeShader
}
}
var timeFromTap by remember { mutableFloatStateOf(0f) }
var lastTap by remember { mutableStateOf(Offset.Zero) }
var isActive by remember { mutableStateOf(false) }
LaunchedEffect(isActive) {
if (!isActive) return@LaunchedEffect
val maxDurationSeconds = 1.5f
var startTime = 0L
withFrameNanos { now ->
startTime = now
}
while (isActive) {
withFrameNanos { now ->
val dt = (now - startTime) / 1_000_000_000f
timeFromTap = dt
if (dt > maxDurationSeconds) {
isActive = false
}
}
}
}
Box(
modifier = modifier
.pointerInput(Unit) {
detectTapGestures { offset ->
lastTap = offset
timeFromTap = 0f
isActive = true
}
}
.drawBehind {
val width = size.width
val height = size.height
runtimeShader.setFloatUniform("u_resolution", width, height)
runtimeShader.setFloatUniform("u_touch", lastTap.x, lastTap.y)
runtimeShader.setFloatUniform("u_time", timeFromTap)
runtimeShader.setFloatUniform("u_active", if (isActive) 1f else 0f)
paint.apply {
shader = runtimeShader
}
drawIntoCanvas { canvas ->
canvas.nativeCanvas.drawRect(
0f,
0f,
width,
height,
paint
)
}
}
) {
content()
}
}
@Composable
fun AgslRippleDemoScreen() {
RippleAgslBackground(
modifier = Modifier.fillMaxSize()
) {
Text(
text = "Тапни по экрану",
modifier = Modifier.align(Alignment.Center),
color = Color.White,
fontSize = 20.sp
)
}
}Волна представляет собой функцию расстояния от точки касания, что выражается в коде как:
float dist = distance(uv, center);
float wave = 0.5 + 0.5 * cos(frequency * (dist - t));
Логика здесь достаточно проста: движение волны наружу обеспечивается выражением dist - t, функция cos создает характерное чередование пиков и впадин, а smoothstep добавляет плавное затухание эффекта. Затем применяются три маски, которые контролируют поведение волны: radiusMask обеспечивает исчезновение эффекта с расстоянием, timeMask отвечает за затухание со временем, а u_active служит переключателем включения/выключения волны.
Этот пример наглядно демонстрирует, как AGSL позволяет создавать выразительные визуальные эффекты с помощью довольно компактной математики.
Compose: ловим тап и запускаем «физику волны»
detectTapGestures { offset ->
lastTap = offset
timeFromTap = 0f
isActive = true
}
При обнаружении жеста касания в блоке detectTapGestures происходит несколько важных действий: сохраняются координаты касания в переменную lastTap, сбрасывается таймер анимации через обнуление timeFromTap и устанавливается флажок isActive в значение true, сигнализирующий о начале анимации. Этот механизм обеспечивает немедленную реакцию системы - как только композиция фиксирует жест касания и выполняет перечисленные операции, шейдер сразу же начинает процесс перерисовки, визуализируя волновой эффект от точки касания.
Анимация времени внутри LaunchedEffect
LaunchedEffect(isActive) {
if (!isActive) return@LaunchedEffect
...
while (isActive) {
withFrameNanos { now ->
timeFromTap = (now - startTime) / 1e9f
if (dt > durationSeconds) isActive = false
}
}
}
В блоке LaunchedEffect(isActive) реализована компактная физическая модель волны: сначала фиксируется начальный момент времени, а затем на каждом кадре анимации вычисляется прошедшее время и обновляется uniform-переменная u_time. Когда продолжительность анимации превышает заданный лимит в 1.5 секунды, эффект автоматически отключается. Важно отметить, что в этом подходе мы не пересоздаем шейдер, а просто изменяем его параметры, что обеспечивает плавную и эффективную анимацию волнового эффекта.
Связка uniform-ов с состоянием Compose
runtimeShader.setFloatUniform("u_resolution", width, height)
runtimeShader.setFloatUniform("u_touch", lastTap.x, lastTap.y)
runtimeShader.setFloatUniform("u_time", timeFromTap)
runtimeShader.setFloatUniform("u_active", if (isActive) 1f else 0f)
В блоке drawBehindпроисходит настройка uniform-переменных шейдера: мы передаем разрешение экрана в u_resolution, координаты последнего касания в u_touch, текущее время анимации в u_time и флаг активности в u_active. Таким образом, устанавливаются четкие связи между состоянием Compose и шейдером: lastTap передается в u_touch, timeFromTap в u_time, а isActive преобразуется в u_active. Вся логика взаимодействия с пользователем и управления состоянием сосредоточена на стороне Compose, в то время как шейдер выполняет свою узкую задачу по пиксельным вычислениям, оставаясь полностью изолированным от знаний о жестах или состояниях приложения.
Эта демонстрация наглядно показывает, что AGSL превосходно справляется с созданием интерактивных элементов -будь то визуальные эффекты, реагирующие на жесты пользователя, динамические фоновые композиции или отзывчивые системы визуализации. Примечательно, что вся эта функциональность достигается всего одним шейдером и тремя uniform-переменными, демонстрируя мощь и эффективность подхода.
Переходим к последнему примеру.
Пример 3: Летящая звезда - AGSL как мини-рендер сценки
Это самый интересный, на мой взгляд, пример: пятиконечная звезда, летящая по траектории, меняющая цвет. Здесь хорошо видно разделение ролей между Compose и AGSL.
Шейдер:
private const val STAR_SHADER = """
uniform float2 u_resolution;
uniform float2 u_center;
uniform float u_active;
uniform float u_phase;
const float PI = 3.14159265;
float sdStar5(float2 p, float rOuter, float rInnerRatio) {
const float2 k1 = float2(0.809016994375, -0.587785252292);
const float2 k2 = float2(-k1.x, k1.y);
p.x = abs(p.x);
p -= 2.0 * max(dot(k1, p), 0.0) * k1;
p -= 2.0 * max(dot(k2, p), 0.0) * k2;
p.x = abs(p.x);
p.y -= rOuter;
float rf = rInnerRatio;
float2 ba = rf * rOuter * float2(-k1.y, k1.x) - float2(0.0, 1.0);
float h = clamp(dot(p, ba) / dot(ba, ba), 0.0, 1.0);
float d = length(p - ba * h);
float s = sign(p.y * ba.x - p.x * ba.y);
return d * s;
}
half4 main(float2 fragCoord) {
float2 uv = fragCoord / u_resolution;
float3 bg = float3(0.05, 0.07, 0.12);
if (u_active < 0.5) {
return half4(bg, 1.0);
}
float2 p = uv - u_center;
float scale = 0.22;
p /= scale;
float dStar = sdStar5(p, 1.0, 0.35);
float starMask = smoothstep(0.01, 0.0, dStar);
float phase = clamp(u_phase, 0.0, 1.0);
float3 colA = float3(0.85, 0.65, 0.10);
float3 colB = float3(0.90, 0.10, 0.10);
float3 starColor = mix(colA, colB, phase);
float radial = 1.0 - clamp(length(p) / 1.0, 0.0, 1.0);
starColor *= (0.9 + 0.1 * radial);
float3 color = mix(bg, starColor, starMask);
return half4(color, 1.0);
}
"""
Реализация основного кода
@Composable
fun StarAgslBackground(
progress: Float,
isActive: Boolean,
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit = {}
) {
val runtimeShader = remember { RuntimeShader(STAR_SHADER) }
val paint = remember { Paint() }
Box(
modifier = modifier
.drawBehind {
val width = size.width
val height = size.height
runtimeShader.setFloatUniform("u_resolution", width, height)
val t = progress.coerceIn(0f, 1f)
val u = 2f * t - 1f // [-1, 1]
val xNorm = 0.5f + 0.45f * u
val yNorm = 0.8f - 0.45f * (1f - u * u)
runtimeShader.setFloatUniform("u_center", xNorm, yNorm)
runtimeShader.setFloatUniform("u_active", if (isActive) 1f else 0f)
runtimeShader.setFloatUniform("u_phase", t)
paint.apply {
shader = runtimeShader
}
drawIntoCanvas { canvas ->
canvas.nativeCanvas.drawRect(
0f,
0f,
width,
height,
paint
)
}
}
) {
content()
}
}
@Composable
fun AgslStarDemoScreen() {
var isFlying by remember { mutableStateOf(false) }
var progress by remember { mutableFloatStateOf(0f) }
LaunchedEffect(isFlying) {
if (!isFlying) return@LaunchedEffect
val durationSeconds = 3.95f
var startTime = 0L
withFrameNanos { now ->
startTime = now
}
while (isFlying) {
withFrameNanos { now ->
val dt = (now - startTime) / 1_000_000_000f
val p = (dt / durationSeconds).coerceIn(0f, 1f)
progress = p
if (dt >= durationSeconds) {
isFlying = false
}
}
}
}
Box(
modifier = Modifier
.fillMaxSize()
.navigationBarsPadding()
) {
StarAgslBackground(
progress = progress,
isActive = isFlying,
modifier = Modifier.matchParentSize()
)
Button(
onClick = {
if (!isFlying) {
progress = 0f
isFlying = true
}
},
enabled = !isFlying,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(16.dp)
) {
Text(
text = if (isFlying) "Звезда в полёте..." else "Запустить звезду",
fontSize = 16.sp
)
}
}
}Шейдер: построение звезды через математическую форму
Вот главный герой:
float dStar = sdStar5(p, 1.0, 0.35);
float starMask = smoothstep(0.01, 0.0, dStar);
Шейдер строит звезду с помощью математической формулы, где ключевую роль играет функция sdStar5(p, 1.0, 0.35), возвращающая значение signed distance field. Эта SDF-функция работает по принципу отрицательных значений внутри звезды, положительных - снаружи, а нулевое значение точно соответствует границе фигуры. Для создания мягких очертаний используется функция smoothstep, которая преобразует резкие переходы в плавные градиенты, благодаря чему звезда приобретает естественные сглаженные края вместо резких контуров, характерных для растровых изображений с алиасингом.
Цвет: звезда «разогревается» во время полёта
float3 colA = float3(0.85, 0.65, 0.10);
float3 colB = float3(0.90, 0.10, 0.10);
float3 starColor = mix(colA, colB, phase);
Цвет звезды динамически меняется по мере её полёта, создавая эффект «разогрева»: изначально звезда имеет золотой оттенок float3(0.85, 0.65, 0.10), который постепенно переходит в ярко-красный float3(0.90, 0.10, 0.10) через функцию mix, использующую параметр phase в диапазоне от 0 до 1. Эта прогрессия цвета от золотого к красному имитирует поведение метеора, врывающегося в атмосферу - начинающий свой путь с холодным свечением и достигающего максимального нагрева к завершению траектории.
Анимация траектории - это уже Compose
val t = progress.coerceIn(0f, 1f)
val u = 2f * t - 1f
val xNorm = 0.5f + 0.45f * u
val yNorm = 0.8f - 0.45f * (1f - u * u)
Анимация траектории полностью реализуется на стороне Compose, где с помощью математических вычислений задается движение объекта. Мы видим, как горизонтальное перемещение происходит линейно слева направо, в то время как вертикальная составляющая описывается параболической траекторией - объект сначала поднимается вверх, а затем опускается. Важно понимать, что AGSL не обладает знаниями о том, как должен двигаться объект; всю эту математику мы описываем в Compose и лишь затем передаём результирующие координаты в шейдер. Это прекрасно демонстрирует, что AGSL служит не в качестве движка анимаций, а выступает графическим слоем, который выполняет свою основную задачу - отрисовку пикселей в соответствии с переданными параметрами.
Uniform-ы, управляющие эффектом
runtimeShader.setFloatUniform("u_center", xNorm, yNorm)
runtimeShader.setFloatUniform("u_active", if (isActive) 1f else 0f)
runtimeShader.setFloatUniform("u_phase", t)
Uniform-параметры, управляющие визуальным эффектом, передаются в шейдер через три ключевые переменные: координаты отрисовки звезды задаются через u_center, флаг активности u_active определяет, нужно ли в данный момент отображать эффект, а параметр u_phase контролирует изменение цвета звезды. Вся остальная вычислительная нагрузка по визуализации и рендерингу эффекта ложится на графический процессор, который эффективно обрабатывает эти данные для создания финального изображения.
Логика полёта - в LaunchedEffect
В блоке LaunchedEffect реализована логика полёта: пока флаг isFlying активен, значение прогресса progress вычисляется как отношение прошедшего времени к общей длительности анимации. Это приводит к непрерывному обновлению координат центра звезды, после чего новые значения uniform-параметров передаются в AGSL-шейдер, что и создаёт плавную иллюзию движения звезды по заданной траектории.
Заключение
AGSL пришёл в Android не как очередной "плюс один" инструмент, а как недостающий слой свободы между дизайнерской фантазией и GPU. Это маленький, компактный и очень честный язык, который позволяет вмешаться ровно туда, где раньше могли жить только OpenGL, SurfaceView или какие-то отдельные пайплайны. Теперь всё это стало родной частью UI.
Три примера, которые мы разобрали, наглядно показывают, что AGSL не требует "входных знаний" в виде OpenGL-опыта. Здесь нет вершинных буферов, нет отдельного render loop, нет необходимости в ручном менеджменте контекстов. Есть только координаты, параметры и цвет. Всё остальное честно делает Skia и графический стек Android.
Главное помнить: AGSL - не про 3D и не про тяжёлые сцены. Он про тонкие, быстрые, локальные эффекты, которые делают интерфейс выразительнее. И именно поэтому он идеально лег в архитектуру Android.
Если ты ещё не пробовал AGSL - самое время. Он удивляет своей простотой: минимальная математика, пара uniform-ов и шейдер начинает «дышать». И чем больше с ним работаешь, тем больше понимаешь, насколько выразительными могут быть интерфейсы без тяжёлых графических стеков и сторонних движков. В этой статье мы коснулись лишь основ: работа с координатами, простые процедурные эффекты, интерактивность. За её рамками остались постобработка изображений, работа с входными ShaderInput, шумы, зерно, мягкие тени, генеративные паттерны, SDF-фигуры, многоступенчатые эффекты, композиция нескольких шейдеров и оптимизация AGSL под разные GPU. Это целый мир, куда действительно хочется углубляться.
Все приведенные в статье примеры, можно пощупать в репозитории к этой статье https://github.com/i-redbyte/AGSL-Basics
Так же есть неплохие примеры в репозитории краснодарского андроид сообщества
Список полезных материалов:
