Site icon AppTractor

Эффект размытия движения для вращающегося колеса в Jetpack Compose

Мы разрабатывали функцию вознаграждения за привлечение рефералов — «колесо фортуны», которое вращается и останавливается на сегменте с призом. Логика анимации работала. Физика казалась правильной. Замедление, перерегулирование, стабилизация. Но наблюдая за вращением на полной скорости, что-то было не так. Это выглядело как вращающийся скриншот, а не колесо. Каждый сегмент был идеально четким, отлично читаемым, даже при скорости 450 градусов в секунду. Настоящие вращающиеся объекты так не выглядят.

Колесу требовалось размытие в движении.

Рисование колеса: основы

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

Колесо представляет собой композабл объект Canvas. Каждый сегмент — это дуга, заполненная радиальным градиентом, обведённая контуром (stroke), с границей из светящихся радиальных линий, и подписанная текстом с ценой, который повернут так, чтобы быть направленным наружу. Все это находится в одной функции расширения DrawScope.

  private fun DrawScope.drawWheelSegment(
      segment: RandomReferralRewardSegment,
      startAngle: Float,
      sweepAngle: Float,
      radius: Float,
      textMeasurer: TextMeasurer,
      segmentInnerColor: Color,
      segmentOuterColor: Color,
  ) {
      val center = Offset(size.width / 2f, size.height / 2f)

      // Glow on radial dividers — Screen blend + blur mask
      drawIntoCanvas { canvas ->
        drawArc(brush = fillBrush, startAngle = startAngle,
          sweepAngle = sweepAngle, useCenter = true,
          topLeft = Offset(center.x - radius, center.y - radius),
          size = Size(radius * 2f, radius * 2f))

        // Outline stroke
        drawArc(color = ColorTokens.neutral1.a76, startAngle = startAngle,
          sweepAngle = sweepAngle, useCenter = true,
          topLeft = Offset(center.x - radius, center.y - radius),
          size = Size(radius * 2f, radius * 2f),
          style = Stroke(width = outlineWidth))

        // Price text, rotated to face outward
        val textLayoutResult = textMeasurer.measure(priceText, textStyle)
        val middleAngle = startAngle + sweepAngle / 2f
        val angleInRadians = (middleAngle * PI / 180f).toFloat()
        val textX = center.x + radius * 0.8f * cos(angleInRadians) - textLayoutResult.size.width / 2f
        val textY = center.y + radius * 0.8f * sin(angleInRadians) - textLayoutResult.size.height / 2f

        rotate(degrees = middleAngle + 90f,
             pivot = Offset(textX + textLayoutResult.size.width / 2f,
                            textY + textLayoutResult.size.height / 2f)) {
          drawText(textLayoutResult, topLeft = Offset(textX, textY))
      }
  }

Затем все сегменты и индикаторные точки объединяются в единую лямбда-функцию:

  val drawWheelContent: DrawScope.() -> Unit = {
      segments.forEach { segment ->
          drawWheelSegment(segment, startAngle, sweepAngle, radius, ...)
      }
      segments.forEachIndexed { index, _ ->
          drawIndicatorDot(startAngle = startAngleOffset + index * sweepAngle, ...)
      }
  }

Это самое важное структурное решение во всей реализации. drawWheelContent — это типизированная ссылка на функцию (DrawScope.() -> Unit), поэтому её можно передавать любому количеству Canvas композабл. Логика рисования определяется один раз; холст можно создавать столько раз, сколько необходимо. Всё, что следует далее, зависит от этого.

Измерение скорости

Интенсивность размытия движения должна отражать реальную скорость вращения колеса. Мы измеряем реальную скорость в Animatable, управляющего вращением, используя snapshotFlow для наблюдения за покадровыми изменениями значений:

  var motionBlurVelocity by remember { mutableFloatStateOf(0f) }

  LaunchedEffect(rotateAnimatable) {
      var prevValue = rotateAnimatable.value
      var prevTimeMs = System.currentTimeMillis()
      snapshotFlow { rotateAnimatable.value }.collect { value ->
          val nowMs = System.currentTimeMillis()
          val dt = nowMs - prevTimeMs
          if (dt > 0) {
              motionBlurVelocity = (value - prevValue) / dt * 1000f // deg/s
          }
          prevValue = value
          prevTimeMs = nowMs
      }
  }

Исходя из скорости, мы получаем множитель размытия blurMultiplier в диапазоне [0, 1]. Фаза 1 вращается с постоянной скоростью 450 град/с. Размытие появляется в половине от этой скорости, поэтому оно не возникает резко:

val phase1VelocityDegPerSec = 360f / LINEAR_ROTATION_DURATION * 1000f // 450 deg/s
  val motionBlurThreshold = phase1VelocityDegPerSec / 2f                 // 225 deg/s

  val blurMultiplier = ((abs(motionBlurVelocity) - motionBlurThreshold) / motionBlurThreshold)
      .coerceIn(0f, 1f)

  val spinDirection = if (motionBlurVelocity >= 0f) 1f else -1f

Обе приведенные ниже реализации размытия управляются одними и теми же двумя значениями: blurMultiplier и spinDirection.

Решение с использованием шейдеров (API 33+)

Физически корректный способ размытия вращающегося объекта — это размытие с накоплением вращения: выборка изображения в нескольких позициях, повернутых относительно текущего кадра, и их смешивание. На Android 13 (API 33) и выше это возможно с помощью AGSL (RuntimeShader), применяемого в качестве RenderEffect непосредственно на компонуемом слое.

Написание шейдера AGSL

AGSL (Android Graphics Shading Language) — это язык шейдинга Google для Android, тесно связанный с GLSL. RuntimeShader получает отрендеренный слой компонуемого объекта в виде uniform-шейдера и может сэмплировать его в произвольных координатах с помощью .eval().

Шейдер принимает 16 сэмплов, каждый из которых слегка повернут в сторону отстающего сэмпла. Весовые коэффициенты линейно уменьшаются от 1,0 в текущем кадре до 0,15 в самом дальнем отстающем сэмпле:

 private const val ROTATIONAL_BLUR_AGSL = """
  uniform shader image;
  uniform float blurAngle;
  uniform float centerX;
  uniform float centerY;
  uniform float spinDir;

  half4 main(float2 coord) {
      const int SAMPLES = 16;
      half4 color = half4(0.0, 0.0, 0.0, 0.0);
      float totalWeight = 0.0;
      for (int i = 0; i < SAMPLES; i++) {
          float t     = float(i) / float(SAMPLES - 1);
          float angle = -t * blurAngle * spinDir;
          float2 delta   = coord - float2(centerX, centerY);
          float cosA     = cos(angle);
          float sinA     = sin(angle);
          float2 rotated = float2(
              delta.x * cosA - delta.y * sinA,
              delta.x * sinA + delta.y * cosA
          ) + float2(centerX, centerY);
          float weight = 1.0 - t * 0.85;
          color       += image.eval(rotated) * weight;
          totalWeight += weight;
      }
      return totalWeight > 0.0 ? color / totalWeight : half4(0.0, 0.0, 0.0, 0.0);
  }
  """

blurAngle — это общий угловой разброс в радианах. spinDir (+1 или −1) гарантирует, что размытие всегда будет отставать от направления вращения.  Вызов image.eval(rotated) считывает данные из отрисованного изображения колеса в повернутой координате — именно это делает размытие вращательным, а не линейным.

Применение шейдера

Вспомогательная функция компилирует программу шейдера и привязывает ее к слою:

  @RequiresApi(Build.VERSION_CODES.TIRAMISU)
  private fun rotationalBlurEffect(
      blurAngleRad: Float,
      spinDirection: Float,
      centerX: Float,
      centerY: Float,
  ): RenderEffect {
      val shader = RuntimeShader(ROTATIONAL_BLUR_AGSL)
      shader.setFloatUniform("blurAngle", blurAngleRad)
      shader.setFloatUniform("centerX", centerX)
      shader.setFloatUniform("centerY", centerY)
      shader.setFloatUniform("spinDir", spinDirection)
      return RenderEffect.createRuntimeShaderEffect(shader, "image")
  }

Строка image — это имя uniform-шейдера в исходном коде AGSL; она указывает системе, какой uniform получает отрендеренный результат слоя.

Это применяется в graphicsLayer основного холста колеса. Обратите внимание, что graphicsLayer { renderEffect } ожидает androidx.compose.ui.graphics.RenderEffect, а не платформенный android.graphics.RenderEffect — преобразование обрабатывается расширением .asComposeRenderEffect():

  Canvas(
      modifier = Modifier
          .matchParentSize()
          .graphicsLayer {
              compositingStrategy = CompositingStrategy.Offscreen
              rotationZ = visualRotation
              if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                  // Max angular spread at full speed: 8 degrees (~0.14 rad).
                  renderEffect = if (blurMultiplier > 0f) {
                      rotationalBlurEffect(
                          blurAngleRad = Math.toRadians(blurMultiplier * 8.0).toFloat(),
                          spinDirection = spinDirection,
                          centerX = size.width / 2f,
                          centerY = size.height / 2f,
                      ).asComposeRenderEffect()
                  } else null
              }
          }
          .padding(16.dp),
      onDraw = drawWheelContent
  )

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

Ограничение уровня API

RuntimeShader был представлен в API 33 (Android 13, кодовое название Tiramisu). Он недоступен в API 31 или 32, хотя сам RenderEffect появился в API 31. Встроенные фабричные методы RenderEffect из API 31 (createBlurEffect, createColorFilterEffect и т. д.) не включают вращательное или направленное размытие — только гауссовское размытие. Пользовательский шейдер AGSL — единственный способ получить вращательное
размытие, и для этого требуется API 33.

Резервный вариант с «призрачным слоем» (API 32 и ниже)

Для устройств с API ниже 33 мы получаем тот же визуальный результат, используя метод, работающий на всех версиях Android: многократно отображаем колесо под немного разными углами поворота, каждый раз с уменьшающейся прозрачностью. При наложении друг на друга результат выглядит как размытое пятно.

Именно здесь преимущество функции drawWheelContent, являющейся многократно используемой лямбда-функцией — мы передаем одну и ту же лямбда-функцию нескольким композабл Canvas. Затраты на отрисовку реальны (каждый обход холста повторно выполняет все заливки сегментов, градиенты, текст и линии свечения), но три дополнительных слоя за 1,5 секунды вращения незаметны на любом устройстве, способном запустить Compose.

 if (blurMultiplier > 0f && Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
      // Closest trail: 2° behind, 80% max opacity
      Canvas(
          modifier = Modifier.matchParentSize().graphicsLayer {
              compositingStrategy = CompositingStrategy.Offscreen
              rotationZ = visualRotation - 2f * spinDirection
              alpha = 0.8f * blurMultiplier
          }.padding(16.dp),
          onDraw = drawWheelContent
      )
      // 4° behind, 60% max opacity
      Canvas(
          modifier = Modifier.matchParentSize().graphicsLayer {
              compositingStrategy = CompositingStrategy.Offscreen
              rotationZ = visualRotation - 4f * spinDirection
              alpha = 0.6f * blurMultiplier
          }.padding(16.dp),
          onDraw = drawWheelContent
      )
      // 6° behind, 40% max opacity
      Canvas(
          modifier = Modifier.matchParentSize().graphicsLayer {
              compositingStrategy = CompositingStrategy.Offscreen
              rotationZ = visualRotation - 6f * spinDirection
              alpha = 0.4f * blurMultiplier
          }.padding(16.dp),
          onDraw = drawWheelContent
      )
      // 8° behind, 30% max opacity (farthest trail)
      Canvas(
          modifier = Modifier.matchParentSize().graphicsLayer {
              compositingStrategy = CompositingStrategy.Offscreen
              rotationZ = visualRotation - 8f * spinDirection
              alpha = 0.3f * blurMultiplier
          }.padding(16.dp),
          onDraw = drawWheelContent
      )
  }

CompositingStrategy.Offscreen принудительно преобразует каждый слой в собственное промежуточное растровое изображение перед смешиванием. Это гарантирует корректную работу операций BlendMode внутри колеса отрисовки (смешивание Screen на линиях свечения) и то, что альфа-канал применяется ко всему композитному слою, а не к каждому пикселю отдельно.

Призрачные слои полностью отсутствуют в дереве композиции, когда blurMultiplier == 0 — условие if означает, что Compose никогда не размещает их и не рисует в состоянии покоя.

Производительность: Canvas в Compose

Canvas composable не вызывает рекомпозиций Compose на каждом кадре. После размещения его onDraw-лямбда выполняется напрямую в render-thread через DrawScope — обёртки Compose здесь представляют собой тонкие адаптеры над нативными примитивами android.graphics.

Само колесо перерисовывается каждый кадр только потому, что меняется rotationZ в graphicsLayer. Это обновление матрицы слоя, которое обрабатывается render-thread без обращения к дереву узлов Compose.

В варианте с шейдером компиляция RuntimeShader происходит один раз при вызове rotationalBlurEffect(). Программы AGSL не кэшируются системой между вызовами, поэтому создание RuntimeShader(AGSL_SOURCE) при каждой рекомпозиции приведёт к компиляции на каждом кадре во время вращения, что является заметной по стоимости операцией.

Если профилирование покажет проблему, решение — сохранить экземпляр RuntimeShader, обновляя его uniform-параметры на месте, а затем также запомнить RenderEffect, который его оборачивает.

В варианте с призрачным слоем каждый дополнительный Canvas полностью заново выполняет drawWheelContent. Четыре ghost-слоя означают четырёхкратное увеличение работы отрисовки во время вращения. На практике на устройствах среднего уровня это не вызывает заметных лагов при вращении около 1.5 секунды, однако это всё равно реальная нагрузка на GPU. Более строгая оптимизация — уменьшать количество ghost-слоёв при падении FPS.

Собираем всё воедино

blurMultiplier — это единый управляющий параметр для обеих реализаций. Ноль означает отсутствие размытия. Единица означает, что шейдер находится в режиме полного углового разброса (или все призрачные слои имеют полную непрозрачность). Переход между этими состояниями осуществляется на основе реальных измеренных скоростей, поэтому размытие естественным образом исчезает во время замедления без какого-либо дополнительного кода — колесо замедляется, blurMultiplier уменьшается, и размытие исчезает.

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

Спасибо за чтение. Удачного композинга.

Источник

Exit mobile version