본문 바로가기

Android/Jetpack Compose

[Compose] 스피닝 휠에 모션 블러를 적용해보자 - Ghost Frames, BlurMaskFilter, RenderEffect 비교

728x90

필자가 회사에서 업무를 진행하다 보면, 룰렛이나 럭키 드로우 같은 회전 UI를 만들어야 하는 경우가 가끔 있다. 단순히 빙글빙글 돌기만 하면 좀 밋밋한데, 빠르게 회전할 때 살짝 흐릿하게 처리되는 모션 블러가 더해지면 한층 더 그럴듯한 느낌이 난다는 것을 게임이나 시뮬레이션 앱을 보면서 느낀 적이 있다.

막상 Compose에서 모션 블러를 어떻게 구현해야 하는지는 의외로 잘 정리된 자료를 찾기가 어려웠다. 최근 안드로이드 관련 기술 블로그를 확인하다가 ProAndroidDev의 "Motion Blur for a Spinning Wheel in Jetpack Compose" 글을 보게 되었고, 세 가지 다른 접근 방식을 비교하는 형태로 정리되어 있어서 매우 인상 깊었다.

이번 글에서는 Jetpack Compose에서 스피닝 휠(룰렛)을 만들고, 거기에 모션 블러를 적용하는 세 가지 기법(Ghost Frames, BlurMaskFilter, RenderEffect)을 각각 구현하고 장단점을 비교해 보고자 한다.


먼저 스피닝 휠부터 만들기

모션 블러를 다루기 전에, 회전하는 휠 자체를 먼저 만들어 보자. Canvas의 drawArc로 부채꼴(sector)들을 둥글게 배치하면 된다.

private val wheelColors = listOf(
      Color(0xFFE53935), Color(0xFFFF9800),
      Color(0xFFFFEB3B), Color(0xFF4CAF50),
      Color(0xFF2196F3), Color(0xFF9C27B0),
      Color(0xFF00BCD4), Color(0xFFFF5722),
  )

  Canvas(modifier = Modifier.size(240.dp)) {
      val radius = size.minDimension / 2f
      val cx = size.width / 2f
      val cy = size.height / 2f
      val sweepAngle = 360f / wheelColors.size

      wheelColors.forEachIndexed { i, color ->
          val startAngle = i * sweepAngle
          drawArc(
              color = color,
              startAngle = startAngle,
              sweepAngle = sweepAngle,
              useCenter = true,
              topLeft = Offset(cx - radius, cy - radius),
              size = Size(radius * 2, radius * 2)
          )
      }
  }
  

회전 애니메이션은 rememberInfiniteTransition으로 만들고, graphicsLayerrotationZ에 적용한다. LinearEasing을 쓰는 이유는 모션 블러 효과를 가장 잘 보여주기 위해서이다(가속/감속이 있으면 블러 강도가 균일하지 않음).

val infiniteTransition = rememberInfiniteTransition()
  val rotation by infiniteTransition.animateFloat(
      initialValue = 0f,
      targetValue = 360f,
      animationSpec = infiniteRepeatable(
          animation = tween(durationMillis = 2000, easing = LinearEasing)
      )
  )

  Modifier.graphicsLayer { rotationZ = rotation }
  

이 상태에서 휠은 매끄럽게 회전한다. 다만 빠르게 돌수록 사람의 눈에는 "딱딱하게 움직인다"는 느낌이 든다. 진짜로 빙글빙글 돈다는 인상을 주려면 모션 블러가 필요하다는 점을 직접 돌려보면서 확인하게 되었다.


기법 1: Ghost Frames (유령 프레임)

가장 고전적이면서도 가장 직관적인 방법이다. "동일한 휠을 살짝 다른 각도로 여러 장 반투명하게 겹쳐서 그린다"가 핵심 아이디어이다. 카메라 셔터스피드가 느릴 때 빠르게 움직이는 물체가 잔상으로 남는 것과 같은 원리이다.

val ghostCount = 8
  val ghostDelta = speed / 60f   // 프레임당 이동 각도
  val baseAlpha = 0.7f

  repeat(ghostCount) { i ->
      val ghostRotation = rotation - i * ghostDelta
      val alpha = baseAlpha * (1f - i.toFloat() / ghostCount)  // 점점 옅어짐

      withTransform({
          rotate(ghostRotation, pivot = Offset(cx, cy))
      }) {
          drawWheelSectors(alpha = alpha)
      }
  }
  

위 코드의 핵심은 alpha가 인덱스에 따라 점점 옅어진다는 점이다. 가장 최근 위치(i=0)는 거의 선명하게, 가장 오래 전 위치(i=7)는 거의 투명하게 그린다. 이렇게 하면 회전 방향으로 자연스럽게 흩어지는 잔상이 만들어진다.

장점

  • API 레벨 제한 없음 (모든 Android 버전에서 동작)
  • 블러 방향이 회전 방향과 정확히 일치 (이 부분이 다른 두 기법보다 우월함)
  • ghostCount, ghostDelta, alpha 조정으로 블러 강도를 정밀하게 제어 가능

단점

  • ghostCount가 많을수록 그리기 호출이 비례해서 늘어남 (8개면 휠을 8번 그림)
  • 매 프레임마다 반복 그리기 → 성능 비용이 가장 큼

기법 2: BlurMaskFilter

안드로이드 네이티브 Paint API에 있는 BlurMaskFilter를 활용하는 방법이다. Compose의 Paint를 만들어서 asFrameworkPaint()로 네이티브 Paint에 접근한 뒤 maskFilter를 설정한다.

val blurPaint = Paint().apply {
      asFrameworkPaint().apply {
          isAntiAlias = true
          maskFilter = BlurMaskFilter(
              radius,                          // 블러 반지름 (px)
              BlurMaskFilter.Blur.NORMAL       // 블러 스타일
          )
      }
  }

  drawIntoCanvas { canvas ->
      canvas.drawCircle(
          center = Offset(cx, cy),
          radius = radius,
          paint = blurPaint
      )
  }
  

BlurMaskFilter.Blur는 네 가지 모드를 제공한다.

  • NORMAL: 내부+외부 모두 블러
  • SOLID: 내부는 선명, 외부만 블러 (그림자 효과에 적합)
  • INNER: 내부만 블러, 외부는 선명
  • OUTER: 외부에만 블러 그림자 (내부는 투명)

장점

  • 코드가 매우 짧고 간단함
  • API 레벨 제한 없음

단점

  • 블러가 모든 방향으로 균등하게 퍼지기 때문에 회전 방향과 무관한 "흐릿함"이 됨
  • 하드웨어 가속 활성화 시 소프트웨어 레이어로 폴백될 수 있어 성능이 떨어짐
  • 모션 블러보다는 "단순 흐림" 효과에 가까움

필자가 직접 시도해 본 결과, 모션 블러로서의 "방향성"은 거의 표현하지 못한다는 한계가 명확했다. 다만 회전이 아니라 단순히 부드러운 흐림 효과를 원할 때는 가장 간단한 선택지가 될 것 같다.


기법 3: RenderEffect (API 31+)

Android 12(API 31)부터 추가된 RenderEffect는 GPU 가속을 활용한 블러를 제공한다. Compose에서는 graphicsLayerrenderEffect 프로퍼티로 사용한다.

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
      Modifier.graphicsLayer {
          rotationZ = rotation
          // x축 블러만 적용 → 반경 방향 블러 흉내
          renderEffect = BlurEffect(
              radiusX = blurRadius,
              radiusY = 0f,
              edgeTreatment = TileMode.Decal
          )
      }
  }
  

여기서 한 가지 흥미로운 트릭은 radiusX만 적용하고 radiusY = 0f로 둔다는 것이다. 양쪽 모두 블러를 주면 단순 흐림이 되지만, 한 축만 블러를 주면 그 방향으로 흐려지는 효과가 만들어진다. 휠이 회전 중일 때는 이게 어느 정도 "회전 블러"처럼 보이는 착시 효과를 준다.

장점

  • GPU 하드웨어 가속 → 성능이 가장 우수
  • 네이티브 블러 품질로 매끄러운 결과
  • 코드가 매우 간결

단점

  • API 31+ 필요 (이전 버전 호환을 위해 분기 처리 필수)
  • 축 방향 블러라서 진짜 회전 방향 블러는 아님 (Ghost Frames보다 정확도 낮음)

속도에 비례한 동적 블러 강도

실제 물리적 회전처럼 보이게 하려면, 회전 속도에 따라 블러 강도가 동적으로 변해야 한다. 천천히 돌 때는 거의 블러 없이, 빠르게 돌 때는 잔상이 강하게 남는 식이다.

val blurRadius = (abs(angularVelocity) / maxVelocity) * maxBlurRadius
  val ghostCount = (abs(angularVelocity) / maxVelocity * maxGhosts).toInt()
  

이 방식을 적용하면 룰렛이 멈출 때 블러가 점진적으로 사라지는 효과까지 자연스럽게 표현된다. 단순히 "회전 = 블러 ON"이 아니라 속도와 연동되니까 훨씬 사실적인 느낌이 만들어진다.


세 가지 기법 비교 정리

기법 API 레벨 성능 방향 정확도 구현 난이도
Ghost Frames 모든 버전 보통(그리기 N회) 높음 보통
BlurMaskFilter 모든 버전 낮음(SW 폴백) 낮음 쉬움
RenderEffect 31+ 높음(GPU) 보통 쉬움

필자가 세 가지를 모두 직접 만들어보고 비교해 본 결과는 다음과 같다.

  • 빠른 회전 + 정확한 회전 방향 블러가 필요하다면 → Ghost Frames
  • 회전이 아닌 단순 흐림 효과면 충분하다면 → BlurMaskFilter
  • API 31+ 보장 가능 + 성능이 가장 중요하다면 → RenderEffect

그리고 한 가지 더 깨달은 점은, "어떤 기법이 가장 좋은가"보다 "내가 표현하고 싶은 게 무엇인가"에 따라 선택지가 달라진다는 것이다. 모션 블러 자체는 단순한 시각 효과지만, 자세히 들여다보면 방향성·강도·성능이라는 세 축의 트레이드오프가 있다는 것을 알 수 있었다.


구현하면서 신경 써야 했던 부분

1. Ghost Frames의 ghostDelta는 속도와 연동

고정값으로 두면 빠르게 돌 때는 너무 좁고 느리게 돌 때는 어색하게 넓다. 속도(angular velocity)에 비례시켜야 자연스럽다.

2. BlurMaskFilter는 하드웨어 가속과 충돌 가능성

특정 환경에서는 소프트웨어 레이어로 폴백되어 성능이 크게 떨어진다. Production에서는 실기기 테스트가 꼭 필요하다.

3. RenderEffect의 radiusY = 0 트릭

모든 방향 블러로 두면 그냥 흐릿하게만 보인다. 한 축만 블러를 주면 그 방향의 모션 블러 느낌이 살짝 살아난다.

4. API 31 분기 처리

RenderEffect는 API 31+ 전용이므로 Build.VERSION.SDK_INT 체크가 필수이다. 이전 버전에서는 Ghost Frames로 폴백하는 식의 패턴이 좋아 보인다.


마무리

이번 글에서는 Jetpack Compose에서 스피닝 휠에 모션 블러를 적용하는 세 가지 기법(Ghost Frames, BlurMaskFilter, RenderEffect)을 직접 구현하고 비교해 보았다.

처음에는 "모션 블러 = RenderEffect 한 줄"이라고 생각했었는데, 막상 만들어보니 진짜 회전 방향의 잔상을 표현하는 데에는 Ghost Frames가 여전히 가장 강력한 도구라는 점이 매우 인상 깊었다. 옛날 게임에서 캐릭터 잔상을 표현하던 방식이 지금도 유효하다는 게 흥미로운 부분인 것 같다.

다음에는 이 모션 블러를 응용해서 빠르게 움직이는 캐릭터나 파티클 효과에도 같은 잔상 기법을 적용해보는 것을 한번 정리해 볼 예정이다.

필자도 아직 Compose의 그래픽 API에 대해서는 공부 중이기 때문에 작성한 개념이나 설명에 오류가 있을 가능성이 있다는 점을 감안하고 읽어주었으면 좋겠다.

해당 게시글에 사용한 예제는 다음 Github에 올려두었다.
https://github.com/HeeGyeong/ComposeSample

 

GitHub - HeeGyeong/ComposeSample: This project provides various examples needed to actually use Jetpack Compose.

This project provides various examples needed to actually use Jetpack Compose. - HeeGyeong/ComposeSample

github.com

728x90