Site icon AppTractor

Mirage: как Cloudy превращается из библиотеки размытия в библиотеку эффектов

Cloudy начиналась как библиотека Compose Multiplatform для размытия для. Modifier.cloudy получает изображение фона за элементом и размывает его — на разных платформах через единый API.

// Cloudy — backdrop blur with Modifier.cloudy
// Modifier.sky captures the background; Modifier.cloudy reads and blurs it.
// https://github.com/skydoves/cloudy

val sky = rememberSky()

Box(modifier = Modifier.sky(sky)) {
  AsyncImage(model = "background.jpg", modifier = Modifier.fillMaxSize())

  Card(modifier = Modifier.cloudy(sky = sky, radius = 20)) {
    Text("Glass Card")
  }
}

Размытие — это один эффект с одним параметром: радиусом. Долгое время этим и ограничивалась вся библиотека. Mirage меняет ситуацию. Это первый шаг к тому, чтобы Cloudy перестала быть просто «библиотекой блюра» и превратилась в универсальный набор графических эффектов — средство запуска пользовательских шейдеров на графическом процессоре поверх содержимого и фона в интерфейсе Compose, а не только фиксированного размытия.

Запустить собственный шейдер на всех платформах сложнее, чем кажется, и проблема не столько в языке шейдеров. Сейчас Mirage преобразует код в AGSL на Android и SKSL в Skiko. Поскольку оба языка относятся к семейству Skia, большинство ядер переносятся почти без изменений. Однако это лишь удобство текущих серверных частей, а не гарантия того, что столь же близким окажется более далёкий язык, например GLSL. Главная сложность заключается в среде выполнения вокруг шейдера. На Android используется RuntimeShader, а в Skiko — Skia RuntimeEffect. У каждого варианта свой способ компиляции программы, привязки юниформов, передачи содержимого и управления жизненным циклом эффектов. Если писать эффект вручную, всю эту инфраструктуру придётся реализовать отдельно для каждой платформы и затем постоянно поддерживать несколько копий в согласованном состоянии. Mirage позволяет описать эффект один раз в виде общего ядра в общем исходном коде и берёт всю платформенную обвязку на себя. Именно это превращает создание «библиотеки эффектов» из утомительной задачи в практичный подход. Кроме того, в будущем можно будет добавить новую платформенную реализацию, не изменяя сами эффекты.

Mirage предоставляет этот программный интерфейс через sealed иерархию Optic. Она включает несколько типов шейдеров, которые можно объединять в упорядоченный план, не затрагивая узел, отвечающий за их биндинг и отрисовку. Добавление эффекта занимает одну строку. Чтобы удалить его, достаточно удалить эту строку. Встроенные эффекты — двухцветная тонировка, радужные переливы тонкой плёнки и зеркальный блик жидкого стекла — лишь первые элементы каталога. Через тот же механизм можно подключать и собственные эффекты.

Демонстрация Mirage: один блок с планом и целый каталог визуальных эффектов. Здесь набор Iridescent применяется к постеру на всю его площадь.

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

Где применяется Mirage

Прежде чем переходить к устройству плана, стоит разобраться, к каким пикселям применяется эффект. План всегда работает с некоторым источником изображения. Таких источников два: собственное содержимое элемента или фон, расположенный позади него. Поэтому Modifier.mirage представлен двумя перегрузками, использующими один и тот же внутренний механизм.

Перегрузка для содержимого

Modifier.mirage { … } применяет план к пикселям, которые отрисовывает сам элемент. Его содержимое передаётся в шейдер, а результат работы шейдера заменяет исходное изображение:

// Mirage — applying an effect to a node's own content
// Modifier.mirage { ... } runs the plan over the pixels the node draws.
// https://github.com/skydoves/cloudy

Box(
  modifier = Modifier.mirage {
    filter(MirageOptics.Chromatic)
  },
) {
  Text("iridescent")
}

Перегрузка содержимого в демонстрации: mirage { filter(Specular) } встраивает преломляющую зеркальную линзу в собственное содержимое узла.

Перегрузка для фона

Modifier.mirage(sky = …) { … } применяет тот же план не к содержимому самого элемента, а к области захваченного фона непосредственно за ним.

Это шейдерный аналог Modifier.cloudy(sky = sky): cloudy размывает фон, а mirage(sky) обрабатывает его с помощью пользовательского шейдерного эффекта.

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

// Mirage — grading the backdrop behind a node
// Modifier.mirage(sky = ...) { ... } runs the plan over the captured backdrop,
// the shader counterpart of Modifier.cloudy(sky = sky).
// https://github.com/skydoves/cloudy

Box(
  modifier = Modifier
    .clip(RoundedCornerShape(24.dp))
    .mirage(sky = sky) { filter(MirageOptics.Duotone) },
)

Элемент считывает область фона непосредственно за собой. Его положение отслеживается по координатам элемента в компоновке — точно так же, как в Modifier.cloudy. Затем эти пиксели последовательно проходят через этапы фильтрации, заданные планом, после чего результат выводится на экран. Собственное содержимое элемента отрисовывается поверх обработанного фона. Поэтому такой модификатор обычно применяют к пустой прозрачной поверхности, чтобы сквозь неё был виден только фон с наложенным эффектом. Для захвата фона выше по иерархии должен находиться элемент с Modifier.sky(sky). Он записывает изображение заднего плана в тот же объект-хранилище, который использует Modifier.cloudy. Во время прокрутки фона эффект обновляется автоматически.

Перегрузка для фона в демонстрационном примере: mirage(sky) { filter(Duotone) } применяет двухцветную тонировку к прокручиваемому списку позади карточки.

В остальном обе перегрузки работают одинаково: используют одни и те же типы Optic, один и тот же блок плана, одинаковую привязку параметров при каждой отрисовке и общий кэш. На Android ниже API 33, где нет RuntimeShader, фильтры просто пропускаются, а исходное изображение выводится без изменений. Благодаря этому резервный вариант спокойно снижает качество эффекта, но не приводит к сбою.

Основная задача: объединение эффектов без изменения логики отрисовки

Рассмотрим распространённый сценарий: нужно последовательно применить к поверхности два эффекта.

Сначала фон окрашивается двухцветным фильтром: тёмные участки преобразуются в один цвет, а светлые — в другой. Например, тени становятся насыщенно-индиговыми, а светлые области — кремовыми.

Затем поверх результата накладывается радужный отблеск фольги, который со временем смещается и переливается.

Без плана пришлось бы напрямую вмешиваться в логику отрисовки. Примерно так:

// Rough sketch of the manual approach — not real Cloudy API.
val duotone = RuntimeShader(DUOTONE_AGSL)   // and a second SKSL copy for skiko
val foil = RuntimeShader(FOIL_AGSL)

Modifier.drawWithCache {
  onDrawWithContent {
    val t = /* your own frame clock */
    duotone.setColorUniform("shadow", indigo)
    duotone.setColorUniform("highlight", cream)
    foil.setFloatUniform("time", t)         // feed every uniform, every frame
    // chain: content -> duotone -> foil, in the right order, by hand
    // …and invalidate each frame so the shimmer moves
  }
}

Для каждого эффекта приходится создавать отдельный RuntimeShader — или его аналог в Skiko, — вручную передавать ему входные значения на каждом кадре, выстраивать эффекты в правильном порядке и самостоятельно запускать цикл анимации перелива. А затем писать всё это дважды: один раз на AGSL и ещё раз на SKSL.

Из этого возникают две проблемы. Первая — каждый эффект оказывается жёстко привязан к конкретному месту отрисовки. Если один и тот же набор из двухцветной тонировки и радужной фольги нужен на десяти экранах, всю настройку приходится копировать десять раз. Если нужно добавить третий эффект, придётся изменять сам цикл отрисовки.

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

План решает обе проблемы. Каждый эффект превращается в самостоятельный объект Optic, который явно указывает свой тип. Затем такие эффекты можно декларативно объединить в упорядоченный список. Чтобы добавить эффект, достаточно одной строки filter(...) или overlay(...). Чтобы удалить его — убрать эту строку. Компиляция, кэширование, привязка параметров и порядок отрисовки становятся обязанностью узла, а не разработчика.

План и иерархия Optic

План — это упорядоченный список эффектов, которые нужно применить. Он описывается в блоке, передаваемом в Modifier.mirage. Обе перегрузки принимают один и тот же блок.

Этот блок выполняется один раз в момент подключения узла и фиксирует список этапов обработки:

// Mirage — a plan is an ordered list of effects (stages)
// The block runs once, when the node attaches, to fix the stage list.
// https://github.com/skydoves/cloudy

Modifier.mirage {
  filter(MirageOptics.Duotone)
  overlay(MirageOptics.Foil)
}

Ключевая деталь здесь — разделение на действия, выполняемые один раз, и действия, выполняемые при каждой отрисовке. Блок плана формирует список этапов только один раз. При этом необязательный блок params для каждого этапа запускается заново при каждой отрисовке. Поэтому чтение состояния снимка внутри params приводит только к повторной отрисовке, но не к повторной композиции.

Цикл кадров, привязка равномерных параметров (юниформ) и повторное использование скомпилированных программ скрыты под этим уровнем абстракции.

Не знакомы с шейдерами и юниформами?

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

Равномерный параметр (юниформ) — это входное значение, например цвет, координата или время, которое передаётся этой программе для каждого кадра.

Даже беглое знакомство с AGSL, языком шейдеров Android, заметно упростит понимание остальной части статьи — Документация по AGSL.

В filter и overlay передаётся объект Optic. Optic представляет собой запечатанную иерархию, причём разделение на разные типы сделано намеренно:

// Mirage — the Optic sealed hierarchy
// FilterOptic feeds the content-filtering path (accepted by filter { });
// GenerateOptic sits outside it and is only accepted by overlay { }.
// https://github.com/skydoves/cloudy

sealed class Optic<P : MirageParams>

sealed class FilterOptic<P : MirageParams> : Optic<P>()   // accepted by filter { }
class ColorizeOptic<P : MirageParams>  : FilterOptic<P>() // point-wise color map
class CompositeOptic<P : MirageParams> : FilterOptic<P>() // free-access, samples content
class GenerateOptic<P : MirageParams>  : Optic<P>()        // content-free, accepted by overlay { }

Каждый тип получает принципиально разный уровень доступа к содержимому, и система типов фиксирует это различие уже на этапе компиляции.

ColorizeOptic выполняет преобразование каждого пикселя независимо. Разработчик пишет только тело ядра: half4 kernel(float2 p, half4 src)

Генератор кода сам добавляет объявления равномерных параметров и стандартную функцию main, которая считывает исходное содержимое. Такой эффект преобразует входной пиксель в новый цвет, не обращаясь к соседним пикселям. К этой категории относятся оттеночные фильтры, цветовые кривые и цветокоррекция. Один из примеров — MirageOptics.Duotone. Он преобразует яркость пикселя в градиент от цвета теней к цвету светлых участков, а параметр интенсивности управляет смешиванием исходного и обработанного изображения.

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

// Mirage — authoring a ColorizeOptic (point-wise)
// You write only the kernel(p, src) body; codegen wraps it with the uniform
// declarations and the standard content-sampling main. This is the real
// MirageOptics.Duotone, verbatim.
// https://github.com/skydoves/cloudy

class DuotoneParams : MirageParams() {
  val shadow: UColor    by uniformColor(Color(0xFF1B1B3A))
  val highlight: UColor by uniformColor(Color(0xFFFFE8C7))
  val amount: UFloat    by uniform(1f)
}

// Reads only the src pixel handed in — never the content sampler.
val DUOTONE_KERNEL = """
half4 kernel(float2 p, half4 src) {
    half g = half(dot(src.rgb, half3(0.2126, 0.7152, 0.0722)));
    half3 dz = mix(half3(shadow.rgb), half3(highlight.rgb), g);
    return half4(mix(src.rgb, dz, half(amount)), src.a);
}
"""

val Duotone = Optic.colorize(
  name = "duotone",
  paramsFactory = ::DuotoneParams,
  agsl = DUOTONE_KERNEL,
  sksl = DUOTONE_KERNEL, // byte-identical on today's Skia-family backends
)

CompositeOptic — это ядро со свободным доступом к исходному изображению. Разработчик самостоятельно пишет полную функцию: half4 main(float2 xy)

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

К CompositeOptic относятся MirageOptics.Specular, MirageOptics.Chromatic, а также готовые варианты тонкоплёночной иризации: OilSlick, SoapBubble, MetallicFoil и Pearl.

// Mirage — authoring a CompositeOptic (free-access)
// You write the full main(xy) yourself; codegen supplies only the uniform
// declarations and the shared lens preamble. Content access is yours:
// content.eval() can sample any coordinate, not just the current pixel.
// https://github.com/skydoves/cloudy

class PixelateParams : MirageParams() {
  val cellSize: UFloat by uniform(12f)
}

val PIXELATE_KERNEL = """
half4 main(float2 xy) {
    float2 cell = floor(xy / cellSize) * cellSize + cellSize * 0.5;
    return content.eval(cell); // sample a coordinate of your choosing
}
"""

val Pixelate = Optic.composite(
  name = "pixelate",
  paramsFactory = ::PixelateParams,
  agsl = PIXELATE_KERNEL,
  sksl = PIXELATE_KERNEL,
)

GenerateOptic — это генератор, который не использует исходное содержимое. Его ядро создаёт пиксели исключительно на основе равномерных параметров. Доступа к исходному изображению у него нет, поэтому попытка обратиться к содержимому отклоняется ещё на этапе преобразования ядра в Mirage, а не приводит к ошибке уже при выполнении на графическом процессоре.

GenerateOptic намеренно не является разновидностью FilterOptic. Система типов не позволяет использовать его в цепочке фильтров: такой эффект можно передать только в overlay.

Один из примеров — MirageOptics.Foil. Он создаёт слой с бликами, радужными переливами и мерцанием, который накладывается поверх результата работы фильтров с выбранным режимом смешивания.

// Mirage — authoring a GenerateOptic (content-free)
// The kernel synthesizes pixels from uniforms alone: there is no content
// sampler, so referencing content is rejected when Mirage lowers the kernel
// (never reaching the GPU). Only overlay { } accepts it — the type system
// keeps it out of the filter path.
// https://github.com/skydoves/cloudy

class GlowParams : MirageParams() {
  val glow: UColor     by uniformColor(Color(0xFFFFD27D))
  val strength: UFloat by uniform(0.6f)
}

// References mirageTime, so the default clock animates it — no manual loop.
val GLOW_KERNEL = """
half4 main(float2 xy) {
    float wave = 0.5 + 0.5 * sin(dot(xy, float2(0.02, 0.03)) + mirageTime);
    float a = wave * strength * glow.a;
    return half4(half3(glow.rgb) * half(a), half(a)); // premultiplied, drawn under the overlay's BlendMode
}
"""

val Glow = Optic.generate(
  name = "glow",
  paramsFactory = ::GlowParams,
  agsl = GLOW_KERNEL,
  sksl = GLOW_KERNEL,
)

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

Нельзя случайно передать двухцветную тонировку в overlay или применить эффект фольги через filter: неправильное сочетание просто не скомпилируется.

Объявление равномерных параметров: MirageParams

Каждый объект Optic содержит подкласс MirageParams, в котором объявляются его юниформы. Это объявление одновременно служит схемой данных. Для каждого равномерного параметра создаётся отдельное делегированное свойство:

// Mirage — declaring uniforms as a schema with MirageParams
// One delegated property per uniform: the property name IS the shader uniform
// identifier, and it exposes a typed handle you write each draw.
// https://github.com/skydoves/cloudy

class DuotoneParams : MirageParams() {
  val shadow: UColor    by uniformColor(Color(0xFF1B1B3A))
  val highlight: UColor by uniformColor(Color(0xFFFFE8C7))
  val amount: UFloat    by uniform(1f)
}

Каждое делегированное свойство одновременно выполняет две задачи. Во-первых, оно задаёт имя равномерного параметра шейдера: имя свойства становится идентификатором, который генератор кода добавляет в итоговый шейдер. Во-вторых, свойство предоставляет типизированный дескриптор — UFloat, UColor, UOffset, USize, UVec4, UTexture и другие. Значение этого дескриптора можно обновлять при каждой отрисовке одним из двух способов: amount(0.5f) или: amount.value = 0.5f. Порядок объявления свойств определяет порядок привязки параметров. Каждый делегат сразу регистрирует свою ячейку через provideDelegate, поэтому индекс ячейки совпадает с порядком свойств в исходном коде. Для этого не требуется рефлексия, что сохраняет совместимость с Kotlin Multiplatform.

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

Эта же система формирует устройство каталога готовых наборов параметров. Готовый набор состоит из ядра и значений параметров по умолчанию. Внешний вид эффекта полностью определяется этими значениями и никогда не зашивается непосредственно в константы шейдера. Именно поэтому пять вариантов тонкоплёночной иризации используют одну и ту же фабрику хроматического эффекта, но разные значения по умолчанию: одна программа для графического процессора — пять разных визуальных эффектов.

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

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

Готовые эффекты с линзоподобной областью действия наследуются от общего класса MirageLensParams. Его значения по умолчанию заслуживают отдельного пояснения:

// Mirage — lens-shaped presets share MirageLensParams
// lensCenter / lensSize default to Unspecified: resolved at bind time to the
// node's center and full size ("auto framing"), so a bare preset covers the node.
// https://github.com/skydoves/cloudy

abstract class MirageLensParams : MirageParams() {
  val lensCenter: UOffset  by uniform(Offset.Unspecified) // auto: node center at draw time
  val lensSize: USize      by uniform(Size.Unspecified)   // auto: node full size
  val cornerRadius: UFloat by uniform(50f)
  val iLight: UOffset      by uniform(Offset(-1f, -1f))   // specular light direction
}

Параметры lensCenter и lensSize по умолчанию имеют значение Unspecified. Во время привязки они автоматически преобразуются в центр узла и его полный размер. Именно это автоматическое кадрирование позволяет использовать готовый эффект без дополнительного блока параметров:

filter(MirageOptics.Chromatic)

В таком случае эффект покрывает весь элемент, к которому применён. Если бы использовались фиксированные значения по умолчанию, линза оказалась бы привязана к началу координат содержимого, а всё за её пределами проходило бы без обработки. На карточке с фоном это выглядело бы так, будто эффект рисуется позади содержимого.

Когда нужна интерактивная линза, следующая за указателем, значения можно переопределять при каждой отрисовке внутри блока filter { }.

Как план превращается в пиксели

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

Как план превращается в пиксели: объявление плана, компиляция и кэширование выполняются один раз при подключении узла. Привязка параметров и отрисовка происходят на каждом кадре с повторным использованием программы из кэша.

Ниже разберём все пять этапов слева направо.

Объявление

Блок плана выполняется один раз и создаёт упорядоченный список объектов StageStage представляет собой запечатанную иерархию с двумя способами применения эффекта:

Каждый этап хранит единственный экземпляр MirageParams, который узел создаёт один раз, а также переданный разработчиком блок paramsBlock, выполняемый при каждой отрисовке.

Компиляция

Каждый объект Optic преобразуется в программу в соответствии со своей категорией. Ядро ColorizeOptic дополняется сгенерированными объявлениями равномерных параметров и функцией main, которая считывает исходное содержимое. Для CompositeOptic добавляются объявления параметров и общая подготовительная часть для геометрии линзы, но собственная функция main сохраняется. GenerateOptic обрабатывается похожим образом, но без средства чтения исходного содержимого. В результате создаётся объект CompiledProgram для конкретного языка шейдеров: текст AGSL на Android или SKSL в Skiko. Он также содержит статические признаки, например usesTime. Этот признак определяется поиском обращения к mirageTime в исходном коде программы.

Кэширование

Скомпилированные программы для графического процессора хранятся в общем для всего процесса кэше. Ключом служит сгенерированный исходный код, а не сам объект Optic.

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

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

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

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

Привязка параметров

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

Отрисовка

На этом этапе применяется правило порядка эффектов. За него отвечает общий объект MirageFilterChain. Фильтры образуют последовательную цепочку эффектов, связанных с содержимым. Нулевой этап записывает исходное изображение: собственное содержимое узла или, в случае перегрузки для фона, смещённую область захваченного заднего плана. Каждый следующий фильтр получает слой, созданный предыдущим этапом.

После выполнения всех фильтров поверх окончательного результата последовательно накладываются эффекты Overlay — в порядке объявления и с указанными режимами смешивания. Этапы не объединяются в одну программу. Каждый из них остаётся отдельной программой, применяемой последовательно. Именно поэтому одна и та же цепочка используется для обеих перегрузок Modifier.mirage.

Сама цепочка не хранит таймер, объект Sky или параметры. Она принимает только уже подготовленные пары Stage.Filter и CachedProgram, а также источник нулевого этапа. Узел содержимого записывает на нулевой этап собственное изображение. Узел фона записывает область из Sky.

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

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

Собираем всё вместе

Когда устройство плана, типов Optic и конвейера обработки уже понятно, полноценный материал для фона можно описать всего несколькими строками:

// Mirage — a full backdrop material: duotone grade + animated foil overlay
// Two stages, two composition rules, zero manual shader wiring;
// the same modifier renders on Android, iOS, Desktop, and Web.
// https://github.com/skydoves/cloudy

Box(
  modifier = Modifier
    .clip(RoundedCornerShape(24.dp))
    .mirage(sky = sky) {
      filter(MirageOptics.Duotone) {
        shadow(Color(0xFF10233F))
        highlight(Color(0xFFFFE3B0))
      }
      overlay(MirageOptics.Foil, blendMode = BlendMode.Screen)
    },
)

Каждый объект Optic отвечает только за одну задачу. Фильтр Duotone применяет к захваченному фону тёплую двухцветную тонировку. Накладываемый эффект Foil добавляет поверх результата мерцающий радужный слой. Поскольку его ядро обращается к mirageTime, встроенный таймер запускает цикл кадров и анимирует переливы.

Два этапа, два правила композиции и никакой ручной настройки шейдеров. При этом один и тот же модификатор, описанный в общем исходном коде, работает на Android, iOS, десктопах и в вебе.

Интерактивный вариант получается таким же компактным. Поскольку блок параметров выполняется при каждой отрисовке, в lensCenter можно передавать координаты указателя, а в iLight — направление, полученное от гироскопа. Эффект будет следовать за вводом без повторной композиции интерфейса.

Если требования меняются, достаточно удалить строку с одним эффектом и добавить вместо неё другой. Узел обновляется на месте, а не создаётся заново. Скомпилированные программы остаются в прогретом кэше, поэтому повторное включение эффекта не требует новой компиляции.

Теперь мы разобрали всю архитектуру:

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

В этом и заключается основная идея Mirage. Modifier.cloudy добавил в Cloudy один эффект. Mirage предоставляет механизм для создания неограниченного количества эффектов из единого описания и предлагает постоянно расширяющийся каталог готовых вариантов.

Размытие больше не является всей библиотекой. Теперь это лишь один эффект среди многих. Именно с Mirage начинается это превращение: Cloudy становится библиотекой графических эффектов, а размытие остаётся лишь первым элементом её каталога.

Источник

Exit mobile version