В последние годы интерфейсы приложений становятся все более интерактивными. Простого эфф��кта нажатия на кнопку уже недостаточно - пользователи ждут живых анимаций и визуальной глубины. Но создание таких эффектов традиционно требовало от разработчиков значительных усилий.

Представь: тебе нужно «поколдовать» над пикселями прямо в 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
Так же есть неплохие примеры в репозитории краснодарского андроид сообщества

Список полезных материалов: