Site icon AppTractor

Горячая перезагрузка AGSL-шейдеров без пересборки: пошаговое руководство для Compose

Когда вы настраиваете анимацию Compose в реальном времени с помощью функции Compose Hot Reload, вы изменяете значение dampingRatio = 0.6f на 0.85f, сохраняете файл и наблюдаете, как пружина по-разному стабилизируется в том же кадре, в котором ваш палец касается экрана. Именно этот цикл обратной связи делает горячую перезагрузку непохожей на обычный цикл пересборки, и в предыдущей статье этой серии о настройке анимаций Compose было объяснено, почему это важно.

Что меняется, когда вы переходите от Animatable и tween к AGSL: строка исходного кода шейдера, встроенная в Kotlin, скомпилированная RuntimeShader, получающая униформы каждый кадр. Некоторые строки в этом файле перезагружаются в режиме горячей перезагрузки. Некоторые — нет. Граница зависит от одной строки кода Kotlin, которую большинство примеров с шейдерами опускают.

В этой статье вы изучите пример мыльного пузыря и точно определите, какие параметры можно настраивать врантайме, почему строка remember(SHADER_SRC) обеспечивает распространение изменений шейдера на запущенную программу, как спецификации анимации на стороне Kotlin сглаживают переходы при изменении числа и какие классы редактирования по-прежнему требуют полной пересборки.

Пример: перетаскиваемый мыльный пузырь

Пример находится в репозитории compose-animations как AnimationExample22.kt. Структура проста: перетаскиваемый шар, поддерживаемый Animatable<Offset>, имеет пружину деформации и значение прогресса лопания, а RuntimeShader считывает все это как униформы, одновременно считывая данные из composable под ним с помощью composable.eval(...). Можно настраивать две поверхности: числовые значения на стороне Kotlin в начале функции и константы AGSL, объявленные внутри main() шейдера.

Авторство и оригинальная работа

Оригинальная физика мыльных пузырей и шейдер тонкой пленки принадлежат Кириакосу Георгиопулосу. Адаптация Compose, обсуждаемая в этом посте, представляет собой порт этой работы на Jetpack Compose, где все настраиваемые значения перенесены в локальные переменные, а константы AGSL хранятся внутри функции main(), чтобы HotSwan мог применять к ним литеральные патчи во время выполнения.

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

Перенесите AGSL константы в тело шейдера

Внутри AGSL main(), пример объявляет параметры толщины и цвета как обычные локальные переменные, а не как униформы:

half4 main(float2 fragCoord) {
  float THICKNESS_BASE = 300.0;
  float THICKNESS_GRAVITY = 120.0;
  float THICKNESS_SWIRL = 100.0;
  float THICKNESS_DETAIL = 40.0;
  float COLOR_INTENSITY = 2.0;
  float EDGE_FADE_END = 0.20;
  float ENV_REFLECTION_STRENGTH = 0.4;
  float ENV_BLUR_RADIUS = 50.0;
  // ... rest of the shader ...
}

Это физические параметры модели тонкой пленки. THICKNESS_BASE управляет средней толщиной мыльной пленки в нанометрах, устанавливая базовое положение радуги. COLOR_INTENSITY умножает итоговый цвет, взвешенный по Френелю, перед добавлением к фону. EDGE_FADE_END управляет скоростью затухания помех до края. ENV_BLUR_RADIUS управляет степенью размытия отражения от окружающей среды.

Почему они объявляются внутри тела шейдера, а не как униформы? Потому что каждая добавленная униформа — это еще одна строка, которую необходимо синхронизировать между AGSL и Kotlin. Локальные переменные в начале функции main() являются частью исходной строки AGSL. Изменение одной из них приведет к изменению всей строки SHADER_SRC на несколько байтов, что означает, что следующий слой сможет обнаружить изменение и перестроить программу.

remember(SHADER_SRC) обеспечивает горячую перезагрузку изменений шейдера

Это самый важный шаблон в файле:

val shader = remember(SHADER_SRC) {
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
    RuntimeShader(SHADER_SRC)
  } else {
    null
  }
}

RuntimeShader компилирует исходный код AGSL при создании объекта. Без ключа, remember { RuntimeShader(SHADER_SRC) } будет кэшировать шейдер на протяжении всей композиции, и редактирование строк AGSL не будет иметь видимого эффекта. «Пузырь» будет продолжать использовать устаревшую скомпилированную программу.

Ключевой параметр SHADER_SRC меняет это. Когда HotSwan вносит изменения в содержимое строки во время выполнения, ключ изменяется, кэш remember становится недействительным, новый RuntimeShader создается из измененной строки, и «пузырь» начинает использовать новую программу в следующем кадре. Старый шейдер удаляется сборщиком мусора. Нет перезапуска процесса, нет пересоздания Activity.

Психологическая модель идентична ключу LaunchedEffect, привязанному к параметру, чтобы он перезапускался при его изменении. Сделайте так, чтобы ключ кэша определялся данными, которые вы хотите отслеживать.

Числовые значения на стороне Kotlin в начале блока

Посмотрите, как начинается пример:

val CARD_HEIGHT_DP = 540f
val MAX_ORB_RADIUS_DP = 180f
val MIN_ORB_RADIUS_DP = 88f
val SPRING_STIFFNESS = 1500f
val SPRING_DAMPING = 34.8f
val POP_DURATION_MS = 150
val POP_DELAY_MS = 1500L
val THEME_REVEAL_DURATION_MS = 1100

Каждое число, влияющее на анимацию, является локальным значением. Ни одно из них не является константой верхнего уровня, ни одно не встроено в глубоко вложенное выражение. Патчер литералов HotSwan перезаписывает их во время выполнения, поскольку они находятся в позиции, где индекс байт-кода стабилен при каждом изменении.

Именно поэтому BubbleState принимает коэффициенты через аргументы конструктора, а не читает захардкоженные значения внутри. Когда BOTTOM_ORB_RATIO меняется с 0.88f на 0.85f, следующая рекомпозиция пересоздаёт BubbleState, потому что список ключей в окружающем remember(...) включает этот коэффициент. Шар занимает новую вертикальную позицию, как только вы сохраняете файл.

Анимируйте значениями, чтобы изменения ощущались как настройка, а не как скачок

Наивная горячая перезагрузка числовой константы просто приведет к скачку. В примере каждое предустановленное значение оборачивается в animateFloatAsState:

val lookSpring = remember {
  spring<Float>(dampingRatio = 0.85f, stiffness = Spring.StiffnessVeryLow)
}
val animInterference = animateFloatAsState(lookValues[0], lookSpring, label = "lookInterference")
val animTintR = animateFloatAsState(lookValues[1], lookSpring, label = "lookTintR")
val animTintG = animateFloatAsState(lookValues[2], lookSpring, label = "lookTintG")
val animTintB = animateFloatAsState(lookValues[3], lookSpring, label = "lookTintB")
val animHueShift = animateFloatAsState(lookValues[4], lookSpring, label = "lookHueShift")

Каждый State<Float> считывается внутри graphicsLayer и передается в качестве униформы каждый кадр. Когда LOOK_PRESET меняется с 0 (мыльный пузырь) на 3 (психоделический), управляемый пружиной animateFloatAsState запускает новую анимацию в направлении новой цели, и пузырь плавно переходит между палитрами в течение нескольких сотен миллисекунд.

Тот же трюк используется и для радиусов:

val animatedMaxRadiusPx by animateFloatAsState(
  targetValue = maxRadiusPx,
  animationSpec = radiusSpring,
  label = "bubbleMaxRadius",
)

Изменение параметра MAX_ORB_RADIUS_DP со 180f до 220f приводит к тому, что пузырь растет в течение действия пружины, а не изменяется скачком. Именно это имеют в виду, когда говорят, что горячая перезагрузка ощущается как работающий микшер, а не как сохранение и обновление. Цикл настройки получает смягчающий слой бесплатно.

Используйте униформы для настройки параметров на разных языках

Некоторые параметры должны быть доступны для настройки с обеих сторон без перекомпиляции шейдера. В качестве примера здесь приведены униформы внешнего вида. interferenceAmount, baseTint и hueShift объявлены внутри блока AGSL:

uniform float interferenceAmount;
uniform float3 baseTint;
uniform float hueShift;

И они пишутся в каждом кадре Kotlin:

shader.setFloatUniform("interferenceAmount", interferenceAmount.value)
shader.setFloatUniform("baseTint", tintR.value, tintG.value, tintB.value)
shader.setFloatUniform("hueShift", hueShift.value)

Преимущество маршрутизации через униформы заключается в том, что вы можете изменять эти значения без перекомпиляции шейдера. Недостаток в том, что добавление нового параметра внешнего вида требует двух изменений: строки с униформами в блоке AGSL плюс соответствующей строки setFloatUniform в Kotlin. Для значений, которые часто меняются (за кадр, за жест), униформы являются корректным решением. Для значений, которые меняются редко, например, фактические константы физической модели, допустимо встраивать их в main().

Что не перезагружается в горячем режиме

Не каждое изменение в шейдере AGSL выживает после сохранения. Две категории по-прежнему нуждаются в полной сборке.

Оба случая имеют одну и ту же границу: они относятся к этапу проектирования, где вы определяете интерфейс шейдера. После того, как униформы и сигнатуры вспомогательных функций установлены, каждая константа внутри main() и каждое значение на стороне Kotlin находятся внутри цикла обратной связи, где и происходит фактическая работа по настройке.

Заключение

В этой статье вы изучили, какие части AGSL шейдера используют горячую перезагрузку в проекте Compose, почему remember(SHADER_SRC) является рычагом, позволяющим изменениям исходного кода шейдера распространяться на работающую программу, как val на стороне Kotlin и animateFloatAsState сглаживают переходы при изменении чисел, и какие классы редактирования по-прежнему требуют полной сборки.

Понимание этих внутренних механизмов поможет вам создать собственные примеры шейдеров таким образом, чтобы их можно было изменять без перезагрузки. Извлекайте физические константы из вложенных выражений и помещайте их в именованные локальные переменные в начале функции main(). Используйте в качестве ключа кэш RuntimeShader исходную строку. Оберните любое значение, которое воспринимает пользователь, в управляемый пружиной animateFloatAsState. Направляйте ввод данных, связанных с жестами, через uniform-переменные, а не через изменения строк.

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

Если вы хотите попробовать этот рабочий процесс на своих собственных шейдерах, установите Compose HotSwan и начните настраивать их визуально.

Как всегда, удачного кодирования!

Источник

Exit mobile version