Когда я впервые всерьёз сел писать AGSL под Android, ощущение было очень странное. С одной стороны — современный графический конвейер, RuntimeShader, RenderEffect, красивые эффекты и весь этот техно-киберпанк. С другой — шейдерный код живёт внутри строковых литералов, uniform-ы приходится объявлять и привязывать вручную, а отладка периодически начинается с философского вопроса: «в какой именно строке я сейчас всё сломал?»
В какой-то момент я поймал очень «кин-дза-дзовское» настроение: вроде перед тобой космическая технология, а инструменты ощущаются так, будто тебе выдали гравицапу без инструкции и сказали: «ну ты это… лети». Можно, конечно. Но хочется, чтобы летели не только самые упорные.
Собственно, так и появился RedByteFX. Я сделал его не только ради собственного удобства. Мне хотелось решить ещё одну задачу: сделать AGSL более массовой и понятной технологией для Android-разработчиков. Потому что в нативном виде AGSL мощный, но психологически для многих выглядит как «не трогай, это на Новый год». А мне хотелось, чтобы к шейдерам можно было подойти без дрожи в коленях и без ощущения, что сейчас придётся разговаривать с господином ПЖ на древнем пацакском наречии.
У библиотеки очень простой принцип:
Kotlin DSL → сгенерированный AGSL → RuntimeShader / RenderEffect
То есть на устройстве по-прежнему исполняется нативный AGSL. Я не подменяю исполнение каким-то своим движком и не прячу магию под ковёр. Я меняю только то, как шейдер пишется и собирается: вместо хрупких строк с шейдером мы получаем типизированный Kotlin DSL, а если хочется убедиться, что всё честно, всегда можно посмотреть итоговую строку через метод agslSource().
В статье я покажу:
-
почему голый AGSL в Android-коде быстро начинает утомлять;
-
как выглядит тот же шейдер в RedByteFX;
-
четыре учебных примера — от простого к сложным эффектам;
-
как устроен DSL: координаты,
uniform-ы,let(...),fn(...),sample(),sampleUv(), стандартная библиотека и интеграция с Compose; -
где библиотека реально выигрывает, а где у неё есть честные ограничения.
Почему голый AGSL в Android-коде так часто ощущается как наказание
Сразу важная оговорка: сам AGSL не плохой. Наоборот, штука мощная и очень полезная. Проблема не в самом шейдерном языке, а в том, как именно мы обычно пишем и сопровождаем его из Kotlin.
Вот где чаще всего начинает болеть:
-
код шейдера живёт внутри строки, а значит IDE помогает сильно меньше, чем могла бы;
-
имена
uniform-ов нужно держать в голове и не ошибаться в строковых ключах; -
рефакторинг становится нервным: переименовал что-то в Kotlin, а строку не обновил — привет;
-
даже средний эффект быстро превращается в суп из
smoothstep,fract,mixи локальных временных переменных; -
в Compose поверх этого ещё появляется обвязка времени выполнения: контроллеры, привязка времени, обновление размеров, инвалидирование;
-
разработчик, который просто хотел «лёгкое красивое свечение», внезапно оказывается в песках Плюка.
Я хотел сохранить силу AGSL, но убрать ощущение, что ты каждый раз ковыряешься отвёрткой в тёмном отсеке корабля.
Подключение
RedByteFX опубликован в Maven Central. Библиотека рассчитана на Android API 33+, потому что опирается на современный стек AGSL / RuntimeShader / RenderEffect.
dependencies { implementation("io.github.i-redbyte:redbytefx-core:1.0.0") implementation("io.github.i-redbyte:redbytefx-compose:1.0.0") implementation("io.github.i-redbyte:redbytefx-stdlib:1.0.0")}
Репозиторий проекта: github.com/i-redbyte/redbytefx
Сначала самое главное: один и тот же шейдер, два совершенно разных опыта
Возьмём самый простой и честный пример: волновое смещение по оси Y. Это демо есть в приложении с примерами как DemoWave.kt.
Вручную на AGSL
uniform shader content;uniform float wave_amplitude;uniform float wave_frequency;half4 main(float2 fragCoord) { float2 offset = float2( 0.0, sin(fragCoord.x * wave_frequency) * wave_amplitude ); return content.eval(fragCoord + offset);}
Формально всё нормально. Но как только вы начинаете расширять эффект, добавлять локальные вычисления, привязку параметров во время работы, элементы интерфейса и несколько вариантов логики — строка быстро перестаёт быть уютным местом.
То же самое на RedByteFX
val effect = redbytefx { val amplitudeUniform = uniformFloat(0f, "wave_amplitude") val frequencyUniform = uniformFloat(0.08f, "wave_frequency") val x = let(fragCoord.x, "x") val waveOffset = let( float2(0f, sin(x * frequencyUniform) * amplitudeUniform), "wave_offset" ) sample(fragCoord + waveOffset)}
Разница тут не в том, что математика «стала другой». Она как раз осталась той же. Но изменился опыт разработки:
-
uniform-ы теперь типизированы и одновременно служат дескрипторами для привязки во время работы; -
let(...)позволяет осмысленно именовать промежуточные шаги; -
финальное чтение входного контента выглядит как
sample(...), а не как ручная возня со строкой; -
код живёт в Kotlin, а значит IDE снова ваш союзник, а не сторонний наблюдатель.
Самое приятное: если хочется доказательств, что DSL не занимается шаманством, можно посмотреть сгенерированный AGSL. Для волны он остаётся почти один в один:
uniform shader uContent;uniform float2 uResolution;uniform float u_amp;uniform float u_freq;half4 main(float2 fragCoord) { return rb_sample( fragCoord + float2(0.0, sin(fragCoord.x * u_freq) * u_amp) );}
И вот это, на мой взгляд, ключевой момент. RedByteFX не прячет AGSL. Он делает так, чтобы до AGSL было приятно дойти живым человеком.
От первого шейдера к эффектам, которые хочется повторить
Ниже четыре реальных примера из демонстрационного приложения. Я специально иду по возрастающей: простой, чуть более сложный, средний и, на мой взгляд, витринный.
1. Wave: самый честный старт
Почему я советую начинать именно с него:
-
он очень близок к чистому AGSL по форме;
-
на нём легко понять, что такое
fragCoord,sample()и пользовательскиеuniform-ы; -
здесь уже видна польза
let(...), но ещё нет ощущения, что вам дали швейцарский нож размером с трактор.

Если бы я объяснял RedByteFX одной фразой, я бы сказал так: это AGSL, которому наконец-то разрешили жить в нормальном Kotlin-коде.
2. Signal: когда появляются функции, маски и процедурная логика
Здесь начинается всё самое интересное. Мы уже не просто искажаем координату, а собираем процедурный эффект из нескольких смысловых блоков:
val effect = redbytefx { val densityUniform = uniformFloat(8f, "signal_density") val lineWidthUniform = uniformFloat(0.08f, "signal_line_width") val amountUniform = uniformFloat(0.85f, "signal_amount") val pulseBand = fn( name = "pulse_band", arg1 = FloatType, arg2 = FloatType, returns = FloatType ) { phase, threshold -> step(threshold, smoothstep(0.08f, 0.92f, fract(phase))) } val base = let(sample(), "base") val uv = let(normalizedUv(), "uv") val grid = let(gridMask(uv, densityUniform, lineWidthUniform), "grid") val scan = let(scanlines(fragCoord.y, 14f, 3f), "scan") val pulse = let(pulseBand(uv.y * densityUniform * 0.5f + grid * 0.35f, 0.55f), "pulse") val hardMask = let(step(0.45f, scan * pulse), "hard_mask") val active = let((grid gt 0.05f) or (hardMask gt 0.5f), "active") val accent = let(color(float3(0.05f, 0.95f, 0.82f), base.a), "accent") val mixed = let(mix(base, accent, min(grid * 0.85f + hardMask * 0.35f, 1f)), "mixed") ifElse(active, mix(base, mixed, amountUniform), base)}
Что здесь важно:
-
fn(...)позволяет вынести вспомогательную AGSL-функцию в отдельный именованный блок; -
normalizedUv()сразу переводит нас в нормализованное пространство координат; -
gridMask(...)иscanlines(...)из стандартной библиотеки убирают процедурный шум и делают код намерения читаемым; -
ifElse(...)оставляет шейдер выражением, а не ломает его на императивные костыли.
В сыром AGSL такой эффект тоже можно написать. Но довольно быстро код превращается в что-то вида «сорок строк математики, из которых через три дня понятна только первая и случайно последняя». А здесь видно историю эффекта: база, UV, сетка, скан, импульс, маска, смешивание. Не просто формулы, а сюжет.

3. Radar: когда стандартная библиотека начинает экономить вам дни жизни
Радар — отличный пример того, где чистый AGSL ещё не ужасен, но уже начинает напоминать технический долг в реальном времени. Полярные координаты, вращающийся луч, дуга, кольца, радиальный градиент, смешивание слоёв — всё это очень любит разрастаться.
val effect = redbytefx { val time by autoUniformTime() val speed by autoUniformFloat(0.72f) val radius by autoUniformFloat(0.34f) val amount by autoUniformFloat(0.86f) val base = let(sample(), "base") val uv = let(fragCoord / resolution, "uv") val polar = let(polarCoordinates(uv), "polar") val sweepAngle = let(fract(time * speed * 0.08f), "sweep_angle") val sweep = let(angularSweep(uv = uv, angle = sweepAngle, width = 0.12f, feather = 0.03f), "sweep") val arc = let( arcMask( uv = uv, radius = radius, ringWidth = 0.09f, angle = sweepAngle, arcWidth = 0.18f, feather = 0.03f ), "arc" ) val outerRing = let(ringMask(uv, radius = radius, width = 0.016f, feather = 0.012f), "outer_ring") val innerRing = let(ringMask(uv, radius = max(radius * 0.58f, 0.08f), width = 0.014f, feather = 0.012f), "inner_ring") val beam = let(radialRamp(uv = uv, innerRadius = float(0.06f), outerRadius = radius + 0.18f), "beam") val mask = let(max(max(sweep * beam, arc), max(outerRing, innerRing)), "mask") val tint = let( color( mix(0.05f, 0.18f, polar.x * 1.4f), mix(0.24f, 1f, sweep + arc * 0.55f), mix(0.10f, 0.62f, polar.y * 0.45f + outerRing * 0.35f), base.a ), "tint" ) val screened = let(maskedScreen(base, tint, mask, amount), "screened") maskedOverlay(screened, color(float3(0.82f, 1f, 0.72f), base.a), arc, amount * 0.32f)}
Вот здесь стандартная библиотека RedByteFX раскрывается во весь рост:
-
polarCoordinates(...)иangularSweep(...)делают полярную логику декларативной; -
arcMask(...),ringMask(...),radialRamp(...)избавляют от копипасты изsmoothstepи ручных кривых затухания; -
maskedScreen(...)иmaskedOverlay(...)позволяют говорить языком композиции, а не языком случайно перемноженных коэффициентов.
Если коротко: «сырой» AGSL тут уже начинает требовать дисциплины уровня «пацак сказал — пацак сделал». RedByteFX позволяет всё ещё думать про эффект, а не про то, сколько раз вы сегодня вручную собрали кольцевую маску.

4. Metaballs: когда шейдер уже начинает выглядеть как живая материя
Здесь библиотека показывает уже совсем другой класс задач. Это не постобработка, не вращающийся луч и не маски поверх готового контента, а вполне процедурная «живая» форма: три SDF-круга сливаются с помощью сглаженного минимума в мягкие неоновые сгустки. И именно в этот момент особенно хорошо видно, что RedByteFX годится не только для аккуратной обвязки AGSL, но и для написания выразительной математики шейдера как нормального Kotlin-кода.
val effect = redbytefx { val timeUniform = uniformTime(name = "meta_time") val blendK = uniformFloat(0.1f, "meta_blend") val uv = let(fragCoord / resolution, "uv") val c1 = let(float2(0.35f + sin(timeUniform * 0.7f) * 0.11f, 0.42f + cos(timeUniform * 0.52f) * 0.09f), "c1") val c2 = let(float2(0.64f + cos(timeUniform * 0.58f) * 0.1f, 0.54f + sin(timeUniform * 0.63f) * 0.08f), "c2") val c3 = let(float2(0.48f + sin(timeUniform * 0.33f) * 0.13f, 0.74f + cos(timeUniform * 0.41f) * 0.07f), "c3") val d1 = let(sdCircle(uv - c1, 0.11f), "d1") val d2 = let(sdCircle(uv - c2, 0.1155f), "d2") val d3 = let(sdCircle(uv - c3, 0.1045f), "d3") val m12 = let(sminPoly(d1, d2, 0.085f), "m12") val field = let(sminPoly(m12, d3, blendK), "field") val blob = softFill(field, feather = 0.035f) val bg = color(float3(0.03f, 0.04f, 0.07f), 1f) val fill = color(float3(0.15f, 0.95f, 0.82f), 1f) val rim = color(float3(0.95f, 0.35f, 0.85f), 1f) val shaded = mix(fill, rim, saturate(blob * 1.15f - 0.35f)) mix(bg, shaded, blob)}
Здесь особенно приятно то, что sminPoly(...) — не встроенная магия, а небольшая вспомогательная функция, объявленная прямо рядом в том же примере. То есть DSL позволяет не только комбинировать готовые кубики, но и спокойно дописывать свою математику шейдера, когда она действительно нужна.
Чем мне нравится этот пример:
-
он резко отличается от
Radarи визуально, и по математике: вместо масок вращающегося луча здесь SDF и слияние полей; -
на нём очень хорошо видно, что DSL годится не только для обвязки AGSL, но и для написания собственных вспомогательных шейдерных функций вроде
sminPoly(...); -
sdCircle(...),softFill(...)и сглаженный минимум превращают сложную процедурную форму в набор читаемых строительных блоков; -
это один из тех примеров, которые отлично смотрятся в анимации: сгустки двигаются, слипаются и, как мне кажется, вызывают желания «поиграться с этим».
Именно на таких примерах особенно видно, почему библиотечный подход выигрывает. На чистом AGSL такие меташары тоже собрать можно, но очень быстро код превращается в липкую субстанцию не только на экране, но и в редакторе. А тут история остаётся прозрачной: центры кругов, поля расстояний, плавное слияние, мягкая заливка, цвет. Как говорится: «Ку!».

Бонус: что ещё посмотреть
В демонстрационном приложении к библиотеке можно найти ещё много эффектов на любой вкус: от цветовых искажений и стеклянных поверхностей до более декоративных, процедурных и почти сценографических штук. Так что если после этой статьи захочется просто походить по примерам и посмотреть, что ещё можно собрать на RedByteFX, демо-приложение для этого подходит идеально.

Cамое вкусное: как устроен DSL RedByteFX
Если воспринимать RedByteFX как «набор готовых заготовок», можно недооценить библиотеку. На самом деле это именно DSL для написания AGSL в Kotlin. Ниже — краткая, но максимально практическая roadmap.
1. Корневые координаты: fragCoord и resolution
Здесь всё максимально похоже на AGSL:
-
fragCoord— текущая координата фрагмента в пикселях; -
resolution— размер области рендера в пикселях.
val uv = fragCoord / resolutionval center = resolution * 0.5f
Если вы переносите существующий AGSL почти один в один, это очень помогает: мозг не делает лишний кульбит.
2. Чтение входного контента: sample() и sampleUv()
Правило простое:
-
sample()иsample(coord)работают в пиксельном пространстве; -
sampleUv(uv)из stdlib работает в нормализованном UV-пространстве[0, 1]; -
sampleUnclamped(...)нужен для осознанных экспериментов с выходом за границы.
Это важный момент, потому что значительная часть ошибок в AGSL-портировании — это именно путаница пространств координат.
val base = sample() // читаем по fragCoordval uv = normalizedUv() // переходим в UVval reread = sampleUv(uv + drift) // читаем повторно уже в UV-пространстве
3. Uniform-ы: типизированные и пригодные для работы эффекта
Вместо строковой магии вы пишете обычный код на DSL:
val amount = uniformFloat(0.5f, "amount")val shift = uniformFloat2(0f, 0f, "shift")val time = uniformTime(name = "time")
Или ещё приятнее — через auto-делегаты:
val amount by autoUniformFloat(0.5f)val time by autoUniformTime()val shift by autoUniformFloat2(0f, 0f)
Почему это круто:
-
имя можно не таскать строкой по всему коду;
-
возвращаемый объект
FxParam— это одновременно выражение внутри DSL и дескриптор для привязки во время работы; -
имя свойства в Kotlin превращается в читаемое имя
uniform-а в сгенерированном AGSL.
Важно: дескриптор uniform-а привязан к конкретному скомпилированному эффекту. Нельзя взять FxParam из одного redbytefx { ... } и безопасно использовать его с другим эффектом, даже если имена похожи.
4. Типы выражений
Внутри DSL всё представлено типизированными узлами выражений:
-
FloatExpr— скалярныйfloat; -
BoolExpr— булево выражение; -
Float2Expr,Float3Expr,Float4Expr— векторы; -
ColorExpr— цветовой результат.
Это не значения времени выполнения, а строительные блоки будущего AGSL. Проще говоря, вы пишете не «программу, которая считает прямо сейчас», а «дерево выражений, которое потом честно превратится в AGSL».
5. Конструкторы значений
val scalar = float(0.5f)val offset = float2(0f, 12f)val rgb = float3(0.2f, 0.8f, 1f)val rgba = float4(rgb, 1f)val tint = color(0.2f, 0.8f, 1f, 1f)
Это вещи, которые в чистом AGSL вы и так делаете постоянно. В RedByteFX просто появляется типобезопасная форма записи.
6. Операторы, сравнения и условия
Обычная математика поддерживается привычно: +, -, *, /. Для сравнений есть lt, lte, gt, gte, eq, neq. Для булевой логики — and, or, !.
val active = (amount gt 0.5f) and (edge lt 0.9f)val mask = ifElse(active, 1f, 0f)
Почему не обычный if? Потому что внутри шейдера нам нужно строить выражение, а не выполнять Kotlin-ветвление на CPU.
7. Базовые встроенные функции
В core уже есть всё, что обычно нужно для AGSL-подобного мышления:
-
mix,clamp,smoothstep,step,saturate; -
sin,cos,atan,pow,sqrt,abs,floor,ceil,fract; -
min,max,mod; -
dot,length,distance,normalize; -
luminance,grayscale.
Мне было важно сохранить близость к AGSL-лексике, чтобы перенос старого шейдера не превращался в перевод с одного языка на совершенно другой.
8. Локальные переменные через let(…)
Это один из моих любимых инструментов в библиотеке. В чистом AGSL вы и так всё время заводите временные переменные. Так почему бы не делать это так же удобно и в DSL?
val base = let(sample(), "base")val luma = let(luminance(base), "luma")val mono = let(grayscale(base), "mono")mix(base, mono, luma)
let(...) сохраняет выражение как локальную переменную в сгенерированном AGSL. Это резко улучшает читаемость больших эффектов. А ещё это очень помогает при отладке через agslSource(): вы видите не мешанину, а именованные шаги.
9. Переиспользуемые функции через fn(…) и fnN(…)
Если в AGSL, написанном вручную, вы бы вынесли вспомогательную функцию, здесь нужно делать ровно то же самое.
val palette = fn( name = "palette_rgb", arg1 = FloatType, arg2 = FloatType, returns = Float3Type) { tone, warmth -> val phase = let(tone * 6.2831855f, "phase") float3( 0.24f + 0.45f * sin(phase + warmth * 0.90f + 0.10f), 0.30f + 0.42f * sin(phase + warmth * 1.50f + 2.10f), 0.42f + 0.36f * sin(phase + warmth * 2.10f + 4.20f) )}
Этот стиль можно посмотреть вживую в DemoDuotone.kt. Если параметров больше четырёх, есть fnN(...).
10. core и stdlib: где заканчивается «чистый DSL» и начинается «удобная библиотека рецептов»
Я бы сформулировал так:
-
redbytefx-core— это язык и минимальный инструментарий. Идеален, когда вы переводите чистый AGSL почти один в один. -
redbytefx-stdlib— это набор высокоуровневых рецептов поверх языка. Он нужен, когда у вас начинают повторяться маски, переходы, световые приёмы, маршруты, полярная логика, SDF и композитинг.
Полезные группы хелперов из stdlib:
-
координаты:
normalizedUv,sampleUv,centeredUv,aspectCenteredUv; -
маски:
circleMask,rectMask,ringMask,arcMask; -
переходы и градиентные маски:
horizontalReveal,verticalReveal,radialReveal,radialRamp,angularSweep; -
смешивание слоёв:
maskedMix,alphaMask,maskedScreen,maskedOverlay,blendMultiply,blendScreen,blendOverlay; -
свет и форма:
rimLight, SDF-хелперы, хелперы маршрутов, шумы и искажения вродеdomainWarp.
Практическое правило очень простое:
-
Если вы портируете чистый AGSL, сначала оставайтесь ближе к
core. -
Когда видите повторяющиеся паттерны, переходите к
stdlib. -
После каждого такого шага смотрите
agslSource(), чтобы сохранить ощущение прозрачности.
Важные элементы тут такие:
-
rememberFxController(effect)создаёт удобный для Compose контроллер для экземпляра эффекта во время работы; -
bindFloat(...),bindFloat2(...),bindTime(...)обновляют параметры без ручной возни; -
Modifier.redbyteFx(fx)применяет эффект к composable.
Если что-то выглядит странно, самый короткий путь диагностики обычно такой: сначала смотрим effect.agslSource(), потом проверяем пространство координат, затем убеждаемся, что привязываем именно те FxParam, которые были созданы этим эффектом.
Небольшая шпаргалка: как мыслить при портировании AGSL в RedByteFX
|
Что было в AGSL |
Чем это становится в RedByteFX |
Комментарий |
|---|---|---|
|
|
тело |
Возвращаем финальный |
|
|
|
И выражение в шейдере, и дескриптор для привязки во время работы сразу. |
|
|
|
Для UV-повторного чтения — |
|
|
|
Можно и напрямую |
|
|
|
Функция остаётся видимой и в сгенерированном AGSL. |
|
|
|
Очень помогает держать большие эффекты читаемыми. |
|
|
|
Потому что внутри DSL мы строим выражения, а не ветвим Kotlin-код. |
Почему библиотечный подход здесь реально выигрывает
Ниже — честная табличка. Не рекламная мантра, а мой практический опыт.
|
Критерий |
Голый AGSL |
RedByteFX |
|---|---|---|
|
Скорость старта |
Порог входа высокий, особенно для Android-разработчика без опыта в шейдерах. |
Заметно ниже: Kotlin-код, типы, демонстрационные примеры и стандартная библиотека. |
|
Читаемость среднего эффекта |
Быстро превращается в строковый техно-борщ. |
|
|
Работа с uniform-ами |
Ручные строки и ручная привязка во время работы. |
Типизированные |
|
Compose-интеграция |
Можно, но требует собственной аккуратной обвязки. |
|
|
Прозрачность исполнения |
Максимальная, вы пишете AGSL напрямую. |
Тоже высокая: всегда можно посмотреть |
|
Рефакторинг |
Нервный: строковые имена, ручные замены. |
Гораздо спокойнее: это обычный Kotlin-код. |
|
Повторное использование паттернов |
Копипаста или ручная поддержка вспомогательных AGSL-функций. |
|
|
Цена за удобство |
Ноль абстракций, но много рутины. |
Нужно освоить DSL, зато дальше работа идёт сильно быстрее. |
|
Итог |
Хорош для точечного низкоуровневого ручного контроля. |
Побеждает почти во всех сценариях реальной разработки. |
Где у библиотеки есть честные ограничения
-
это Android API 33+; если вам нужен старый стек, чудес не будет;
-
если вам важен полностью ручной контроль и вы предпочитаете писать шейдеры прямо на AGSL, этот путь по-прежнему открыт;
-
как и любой DSL, RedByteFX требует один раз освоить модель мышления: выражения, пространства координат, сгенерированный AGSL и параметры, привязанные к конкретному эффекту.
Но, на мой взгляд, это честная цена. Особенно если сравнить её с тем, сколько боли экономится уже на втором или третьем эффекте.
Что я в итоге хотел получить и что получилось
Мне хотелось, чтобы AGSL в Android перестал быть технологией «для тех, кто уже пережил три ритуала посвящения». Чтобы разработчик мог открыть sample, посмотреть на разные эффекты — и не подумал «ой нет, это не для меня», а подумал: «О, а я же могу это попробовать сегодня вечером!».
Если это ощущение у вас сейчас появилось, значит я всё делал не зря.
Финал
RedByteFX — библиотека свежая. Это не «всё, высечено в камне», а живой проект, который я продолжаю развивать. Поэтому мне особенно интересны люди, которым хочется не просто посмотреть, а попробовать, поругать по делу, предложить идеи, завести issue или принести PR.
Если вам близка идея сделать AGSL в Android более массовым, дружелюбным и при этом не потерять нативность исполнения — добро пожаловать. Репозиторий здесь: github.com/i-redbyte/redbytefx.
Если после знакомства с этой библиотекой у вас появилось ощущение, что эцилоп перестал бить вас по ночам, значит всё было не зря.
ссылка на оригинал статьи https://habr.com/ru/articles/1022546/