영화로부터 잘 알려진 것처럼 애니메이션(Animation)은 어떤 사물의 움직임 내지 장면의 변화를 표현하는 기술이다. 특히 애니메이션 기술은 만화나 영화, 게임 등 여러 영상 콘텐츠에서 자연스러운 동작 표현을 위해 사용되었다. 이러한 애니메이션은 UI에서도 예외가 아니다. UI 시스템은 애니메이션을 통해 콘텐츠뿐만 아니라 UI 객체의 상태 전이를 시각적으로 부각할 수 있다. 이는 사용자의 시선을 문맥의 흐름에 집중시킴으로써 사용자 이해를 높이고 앱의 사용성을 개선하는 데 도움을 준다. 무엇보다도 애니메이션을 통해 UI 앱의 콘텐츠를 보다 생동감 있게 표현함으로써 사용자 이목을 집중시키는 한편 앱의 가치를 높일 수 있다.
한편, UI 시스템에서 비주얼 인터렉션(Visual Interaction)은 애니메이션과 동일한 범주 내에서 해석할 수 있다. UI 앱은 사용자 입력 내지 제스처로부터 데이터를 가공한 후 모션 그래픽 효과를 애니메이션과 함께 표현할 수 있다. 이러한 애니메이션 효과는 장면마다 연속된 이미지를 화면에 출력함으로써 개체가 마치 어떤 동작을 수행하는 것처럼 표현한다.
본 장에서는 UI 시스템에서 애니메이션과 함께 비주얼 인터렉션을 구현하기 위한 주요 기술을 구체적으로 살펴본다. 비주얼 인터렉션은 오늘날 UI의 꽃이라고 해도 과언이 아니며 고급 UI를 위한 필수 요소에 해당하므로 사례와 함께 구현 메커니즘을 깊이 있게 학습해 보도록 한다.
1. 학습 목표
이번 장을 통해 다음 사항을 학습해 보자.
2. 애니메이션 기초
이번 절은 6.3절 실용 애니메이션 기술의 기반이 되는 기술 사항으로서 UI 엔진 구축에 중요한 사항에 해당한다. 본 절에서는 UI 시스템에서 필요한 애니메이션 필수 기반 요소에 대해서 살펴본다. 프레임 애니메이션을 통해 애니메이션 동작 원리를 학습하고 그 과정에서 애니메이션 루프, FPS, 시간 개념 등 애니메이션 주요 개념을 이해한다.
2.1 프레임 애니메이션
프레임(Frame) 애니메이션의 원리는 기록한 장면을 순차대로 출력한다. 전통 애니메이션 영화를 떠올리면 쉽게 이해할 수 있다. 일반적으로 프레임 애니메이션은 장면 이미지를 디자인 단계에 미리 가공하므로 앱에서 필요한 리소스는 크지만 준비한 이미지를 시간 순서대로 출력하면 되므로 구현은 단순하다.
프레임 애니메이션 동작 이해를 위한 간단한 예시로서 그림 6.1과 같이 연속된 장면의 이미지를 시간 순서대로 화면에 출력한다.
요약하자면, 프레임 애니메이션을 구현하기 위해서는 다음 두 사항을 고려해야 한다.
이를 코드로 옮기면 다음과 같다.
/* * 10개의 장면으로 구성된 애니메이션을 구현한다. * 본 예제에서는 이미디어트 렌더링 방식을 이용한다. */ frameAnimation(): //장면 1 준비 img1 = UIImage(): .path = “frame0.jpg” .geometry = {x, y, w, h} //첫 번째 장면을 그리기 위해 무효 영역을 설정 invalidateArea(x, y, w, h) //화면 갱신 요청 drawScreen() //장면 2 준비 img2 = UIImage(): .path = “frame1.jpg” .geometry = {x, y, w, h} //두 번째 장면을 그리기 위해 무효 영역을 다시 설정 invalidateArea(x, y, w, h) //화면 갱신 요청 drawScreen() //동일하게 열 번째 장면까지 반복 작업을 수행
...
- 애니메이션 루프
/* * 10장의 장면으로 구성된 애니메이션을 구현한다. * 본 예제에서는 리테인드 렌더링 방식을 이용한다. * 앱은 애니메이션 콜백 함수를 등록하고 콜백 함수에서 장면을 구축한다. */ frameAnimation(): /* 애니메이션 객체를 하나 생성한다. UIAnimation은 UIEngine으로부터 이벤트 신호를 받고 프레임마다 f()를 호출한다.*/ animation = UIAnimation()
/* 애니메이션 콜백 함수를 클로져(Closure) 방식으로 표현.
현재 프레임에 해당하는 장면을 준비한다.
img 객체는 미리 초기화되어 있다고 가정한다. */
f = closure():
//새로운 장면 이미지로 교체
img.path = "frame" + frame + ".jpg" //path = frame0.jpg
++frame; //다음 프레임 번호 결정 (frame1)
animation.EventUpdated += {f, ...}
//애니메이션 가동 animation.play()
그림 2: 애니메이션 루프 수행 과정
사용자 애니메이션과 메인루프를 연결함으로써 애니메이션 루프를 완성한다. 이때 메인루프와 UIAnimation을 연결하고 애니메이션을 일괄 중재하는 UIAnimationCore 클래스를 도입하여 구조를 체계화한다. UIAnimationCore는 생성된 UIAnimation 인스턴스를 리스트로 관리한다. UIEngine은 메인루프의 이벤트 처리 단계에서 UIAnimationCore를 통해 UIAnimation 갱신 작업(update)을 일괄 수행한다. 이때 UIAnimation은 사용자 콜백 함수 f()를 호출하여 사용자 애니메이션을 수행한다.
//UIAnimation은 UIAnimationCore에 자신의 인스턴스를 등록한다. UIAnimation.constructor(): UIAnimationCore.register(self)
- GIF 애니메이션
//UIAnimatedImage는 애니메이션 재생 가능한 이미지 기능을 담당한다. img = UIAnimatedImage(): .path = “sample.gif” //GIF 파일을 불러온다. .geometry = {x, y, w, h} .play() //애니메이션 재생
/* * 이미지 애니메이션 재생 */ UIAnimatedImage.play():
.animation = UIAnimation() //애니메이션 생성
/* 애니메이션 구현 함수를 클로져 형식으로 표현 animation은 UIAnimation 자신을 가리키고 target은 호출한 UIAnimationImage 객체를 가리킨다. */ f = closure(UIAnimation animation, UIAnimatedImage target):
//다음 프레임 번호 결정
++target.frame //프레임 번호가 끝에 도달하면 애니메이션 종료 if(target.frame == target.maxFrame) animation.finish()
.animation.EventUpdated += {f, self} .animation.play()
2.2 프레임 제어
- FPS (Frames Per Second)
FPS는 프레임률(Frame Rate)라고도 하며 초당 화면 출력 수를 의미한다. 따라서 FPS가 높을수록 부드러운 애니메이션 표현이 가능하다. 다만 현대의 콘텐츠에서 애니메이션은 60fps를 지향하는데 프레임률이 60을 초과하더라도 시청자는 그 차이를 인지하기 어렵기 때문이다. 오히려 과도한 프레임률로 인해 프로세싱 부하와 전력 소모가 증가한다. 반대로 시청자는 60fps 미만에서 애니메이션이 부드럽지 않다는 느낌을 받을 수 있고 30fps 미만이라면 그 차이를 쉽게 감지할 수 있다.
이런 이유로 산업에서는 출력 장치 화면 주사율을 60Hz에 맞게 설계한다. 이 수치는 일부 게이밍 또는 3D 모니터를 제외하고 현재 상용 제품의 표준 수치로서 통용된다. 따라서 소프트웨어 역시 디바이스 성능을 바탕으로 60fps 동작을 지원해야 한다.
이제 실제 동작 예시를 들어보자. 60fps의 3초짜리 애니메이션을 출력한다면 총 180장면의 이미지를 생성해야 한다. 이는 UIEngine.run()에 의해 수행되는 메인루프의 반복 횟수가 총 180번임을 의미하며 초당 60번을 반복해야 한다. 달리 말하면 메인루프는 1초당 60번의 화면 갱신(렌더링) 작업을 수행해야 한다.
따라서 60fps를 보장하기 위해서는 애니메이션 장면을 준비하고 출력하는 과정을 약 0.0167초 이내에 완성해야 한다. 만약 메인루프의 한 사이클을 수행하는 데 걸리는 시간이 이보다 적다면 60fps를 초과할 수도 있다. 이 경우엔 60fps를 초과하지 않도록 메인루프 동작을 지연시켜야 한다. 반대로 메인루프 한 사이클을 수행하는데 0.0167초를 초과한다면 60fps를 보장할 수 없다. 이 경우 우리가 고려할 수 있는 해결책은 소프트웨어를 최적화하거나 고성능의 하드웨어 장비로 교체하는 것이다.
- 수직 동기화
시스템이 수직 동기화(vsync)를 요구한다면 시간 엄수는 더 엄격하다. 특히 멀티 윈도우 환경이라면 디스플레이 장치는 여러 클라이언트(앱)가 공유하는 자원에 해당하므로 한 클라이언트만을 위해 화면 갱신(refresh) 작업을 수행할 수 없다. 이러한 구조에서는 윈도우 서버/컴퍼지터가 초당 60회로 클라이언트(UI 앱)의 화면을 합성하여 출력한다. 이는 마치 시간을 엄수하며 출발하는 기차와도 같다. 따라서 컴퍼지터가 화면 갱신 작업을 수행하기 전에 클라언트가 화면 출력을 완수하지 못한다면 윈도우 서버의 다음 프레임 출력까지 클라이언트는 출력 결과물을 화면에 내보낼 수 없게 된다. 따라서 이 경우 클라이언트의 출력 장면은 다음 프레임으로 미루거나 철회한다.
스크린 티어링 (Screen Tearing) 스크린 티어링(화면 찢김 현상)은 한 화면에 여러 프레임의 이미지가 동시에 기록되는 현상이다. 이는 출력 버퍼에 렌더링 장면을 기록하는 도중에 출력 버퍼 장면을 화면상으로 내보내어 발생하며 마치 화면이 찢어지는 현상처럼 보인다. 이를 방지하기 위해 다중 버퍼를 이용하거나 화면 주사가 완전히 끝날 때까지 출력 버퍼 기록 작업을 대기하여 동기화 작업을 수행한다. |
- 더블 / 트리플 버퍼링
한편 클라이언트가 렌더링 결과물을 출력 버퍼에 기록하는 동안 윈도우 서버가 클라이언트의 출력 버퍼에 접근하기 위해 이중 또는 삼중 출력 버퍼를 이용할 수 있다. 이를 더블(Double) 또는 삼중(Triple) 버퍼링이라고 한다. 이러한 기법을 이용하면 스크린 티어링 현상을 방지하고 클라이언트는 앞서 말한 지연된 프레임을 버퍼에 보존한 채 다음 프레임 장면을 구축할 수 있다. 클라이언트의 화면 출력 작업이 지연될 경우 윈도우 서버는 클라이언트의 주 버퍼(Primary Buffer)에 저장된 이전 화면을 그대로 활용한다.
2.3 시간 측정
- 타임스탬프 (Timestamp)
/* * Linux clock_gettime()을 이용한 타임스탬프 확인 * UIEngine은 시간을 확인하기 위해 time()을 호출한다고 가정
* 코드는 C 언어로 작성 */ #include <time.h>
double time() { struct timespec timestamp; double seconds; //실시간 시간 정보를 timestamp에 기록 clock_gettime(CLOCK_REALTIME, ×tamp); //timestamp에 기록된 시간을 초 단위로 환산 //Nanoseconds -> Microseconds -> Milliseconds -> Seconds seconds = timestamp.tv_sec + (timestamp.tv_nsec / 1000000000L); return seconds; }
타임스탬프의 시간을 이용하여 이전 사이클 시간과 현재 사이클 시간을 각각 기록하고 두 시간의 차, 즉 경과 시간(Elapsed Time)을 통해 한 프레임에 걸린 시간을 확인한다. 만약 경과 시간이 0.0167초보다 적다면 남은 시간만큼 대기(idle) 또는 수면(sleep) 상태로 메인루프의 동작을 지연시킴으로써 한 프레임을 완수하는 데 걸리는 시간을 조율한다.
그림 5: 프레임 경과 시간 계산
반대로 0.0167초를 초과한 경우 윈도우 서버는 클라이언트 출력물을 화면에 반영 못 할 가능성이 높기 때문에 해당 프레임은 누락되었거나 지연되었다고 해석할 수 있다. 이때 더블 또는 트리플 버퍼링을 적용한 경우 백버퍼(Secondary Buffer)에 출력물을 기록한 채 시간이 지연된 만큼 바로 다음 프레임을 수행한다.
CPU Scaling Governor
정확한 FPS를 측정하기 위해 CPU 주파수(Frequency)를 조율하는 방법을 배워두자. 리눅스에서는 CPU 주파수를 실시간 조절함으로써 프로세서 파워를 제어한다. 본 정책을 CPU Scaling Governor라고 하는데 하드웨어 성능에 민감한 임베디드 시스템에서는 CPU 주파수를 조절함으로써 프로세서 파워를 낮추고 전력 소모를 최소화한다. 문제는 이 정책으로 인해 성능 측정 중 프로세서 파워가 바뀔 수 있으며 이는 성능 측정 결과에도 적지 않은 영향을 미칠 수 있다. FPS를 통해 성능 비교를 할 경우 측정 환경은 동일해야 하므로 성능 측정 시 프로세서 파워를 고정하는 작업이 필요하다.
리눅스에서는 CPU 주파수 제어 정책을 정의한다. 다음 명령어를 통해 현 시스템에서 설정 가능한 정책을 열람한다. $cat /sys/devices/system/cpu/cpu*/cpufreq/scaling_available_governors
performance, powersave, userspace, schedutil $echo performance > /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor 1200000 $cat /sys/devices/system/cpu/cpu*/cpufreq/scaling_max_freq 3300000 현 시스템에서 가용한 CPU 주파수 목록을 확인한다. $cat /sys/devices/system/cpu/cpu*/cpufreq/scaling_available_frequencies 1200000 2400000 3300000 프로세서를 최대 파워로 동작하기 위해 scaling_min_freq와 scaling_max_freq를 최대 수치(3300000)로 지정한다. $echo 3300000 > /sys/devices/system/cpu/cpu*/cpufreq/scaling_min_freq $echo 3300000 > /sys/devices/system/cpu/cpu*/cpufreq/scaling_max_freq cpu*의 *은 cpu 번호로 대처한다. 예로, 8코어는 cpu0, cpu1, cpu2, cpu3 … cpu7. 멀티 코어를 활용한다면 모든 CPU를 대상으로 같은 작업을 수행한다. 더욱 자세한 사항은 다음 문서를 참조하자. https://www.kernel.org/doc/html/v4.14/admin-guide/pm/cpufreq.html |
2.4 시간 제어
코드 5 GIF 애니메이션 재생부를 확장하여 시간 제어 로직을 추가한다. 코드 5는 프레임(Frame) 애니메이션을 구현하는데 지정된 시간 동안 애니메이션을 재생하기 위해서는 시간 제어가 필요하다. 예를 들어 애니메이션 재생 시간을 3초로 가정한다면 UIAnimation의 수행 로직인 f()는 60fps 수행 환경에서 3초 동안 180번 호출된다.
일단 UIAnimation 내부 구현은 무시한 채 UIAnimation()이 수행 시간을 추가 인자로 받고 콜백 함수에서 애니메이션 재생 흐름(progress)을 인자로 전달한다고 가정한다.
UIAnimatedImage.play(): /* UIAnimation()에 전달한 duration을 통해 애니메이션 수행 시간을 지정한다.
duration은 3초라고 가정한다. */ .animation = UIAnimation(.duration)
/* progress는 0 ~ 1사이 정규값이다. 0은 애니메이션 시작 시점, 1은 애니메이션 종료 시점에 해당한다. */
f = closure(UIAnimation animation, UIAnimatedImage target, Var progress): target.frame = target.maxFrame * progress //다음 프레임 번호 결정
/* 이제 UIAnimation은 애니메이션을 스스로 종료할 수 있음으로 다음 호출은 필요 없다. */
//if(target.frame == target.maxFrame) animation.finish()
.animation.EventUpdated += {f, self}
/* play()는 루프타임을 이용하여 시작 시간을 기록한다고 가정한다. */ .animation.play()
UIAnimationCore.update(): /* 루프 타임 갱신 */ .loopTime = UITime.loopTime() /* UIAnimation 갱신 */
foreach(.animations, animation)
animation.update(.loopTime)
/* 갱신 후 애니메이션이 종료된 경우 리스트에서 제거 */
if(animation.invalid()) .animations.remove(animation)
UIAnimation.update(current): /* progress 값을 구한다. begin은 play() 수행 시 기록한 시작 시각이며
duration은 애니메이션 재생 시간이다. */ progress = (current - .begin) / .duration /* 시간 초과에 주의 */
if(progress > 1) progress = 1
/* 콜백 함수를 호출한다. 마지막 인자로 progress를 전달하는 것에 주목 */
.userFunc(self, .target, progress)
/* 애니메이션 종료 */
if(progress == 1) .finish()
- 이징 애니메이션 (Easing Animation)
UI 프레임워크는 애니메이션의 심미적 효과를 높일 수 있도록 이징(Easing)을 정의한다. 이징은 애니메이션의 속도 개념으로 볼 수 있으며 타입에 따라 UIAnimation의 progress 값에 변화를 준다. 방법에 따라 이징 애니메이션은 다음과 같은 속도 곡선을 보인다.
그림 6: Easing Animation 곡선 예시
- 인터폴레이터 (Interpolator)
Rewind [1 0.75 0.5 0.25 0] Bounce [0.125 0.2 0.4 0.8 1.125 1.2 1.125 1.0] |
Rewind와 Bounce Progress 예시
/*
* UIInterpolator 인터페이스를 구현한다.
* 멤버 함수 map()은 인자 progress[0 - 1] 값을 사인 곡선[0 - 90]에 매핑한 값으로 변환한다.
*/ UIEaseOutInterpolator implements UIInterpolator:
override map(progress):
return sin(progress * 90)
UICustomInterpolator implements UIInterpolator:
override map(progress):
//TODO: 원하는 그래프 곡선을 구현하고 progress 값을 변환한다.
return xxx(progress)
UICustomInterpolator myInterpolator //사용자 커스텀 인터폴레이터
animation = UIAnimation()
...
animation.interpolator = myInterpolator //인터폴레이터 적용
animation.play()
3. 애니메이션 기법
앞 절에서 배운 애니메이션 기초를 토대로 본 절에서는 UI 프레임워크에서 활용할 수 있는 애니메이션 기법에 대해서 설명한다.
3.1 프로퍼티 애니메이션
프로퍼티 애니메이션은 UI 오브젝트의 속성(Property)에 변화를 주어 애니메이션 효과를 나타낸다. 일부 프레임워크에서는 이를 트윈(Tween) 애니메이션라고도 한다. 핵심은 변화를 주고자 하는 오브젝트 속성을 결정하고 이 속성을 매 프레임마다 새로 갱신한다. 대표 속성으로는 오브젝트 위치, 크기, 색상, 투명도(Opacity)가 있으며 사실상 프레임워크에서 허용하는 변경 가능한 속성은 모든 대상이 될 수 있다.
예시로 이동 애니메이션을 표현하기 위해 객체 위치 속성을 변경한다. 객체의 시작 위치와 종료 위치 정보를 입력으로 받고 두 위치 사이에서 UIAnimation progress를 이용하여 현재 객체 위치를 구한다.
/* 현재 프레임의 객체 위치를 구한다.
prop은 Property 타입으로서 이는 프로퍼티 애니메이션을 위해 정의한 데이터다.
toPos와 fromPos는 객체 시작 위치와 종료 위치를 가리킨다.
target은 프로퍼티 애니메이션을 적용할 대상 객체를 가리킨다. */ UIPropertyTransition.play():
...
f = closure(UIAnimation animation, UIObject target, Var progress):
curPosition = (prop.toPos - prop.fromPos) * progress + prop.fromPos target.position = curPosition ...
색상의 경우도 다르지 않다.
/* 현재 프레임의 객체 색상을 구한다.
toColor와 fromColor는 시작 색상과 종료 색상을 가리킨다. */ UIPropertyTransition.play():
...
f = closure(UIAnimation animation, UIObject target, Var progress):
curColor.r = (prop.toColor.r - prop.fromColor.r) * progress + prop.fromColor.r curColor.g = (prop.toColor.g - prop.fromColor.g) * progress + prop.fromColor.g curColor.b = (prop.toColor.b - prop.fromColor.b) * progress + prop.fromColor.b target.color = curColor ...
//위치 curPosition = (prop.toPos - prop.fromPos) * progress + prop.fromPos target.position = curPosition //색상 curColor.r = (prop.toColor.r - prop.fromColor.r) * progress + prop.fromColor.r curColor.g = (prop.toColor.g - prop.fromColor.g) * progress + prop.fromColor.g curColor.b = (prop.toColor.b - prop.fromColor.b) * progress + prop.fromColor.b target.color = curColor //크기 curSize = (prop.toSize - prop.fromSize) * progress + prop.fromSize target.size = curSize //투명도 curOpacity = (prop.toOpacity - prop.fromOpacity) * progress + prop.fromOpacity target.opacity = curOpacity
한편, 프로퍼티 애니메이션을 이용하는 UI 앱은 효과를 주고자 하는 속성값을 지정해야 한다. 속성값은 코드 15의 toPosition, toColor, toSize, toOpacity에 기록된다. 이때 UI 프레임워크는 사용자가 프로퍼티 값을 지정할 수 있도록 편의 인터페이스를 제공해야 한다. 이를 위해 UIPropertyTransition 라는 가상의 클래스를 도입했다. UIPropertyTransition은 UIAnimatedImage와 동일하게 UIAnimation을 has-a 관계로 구축하여 애니메이션을 수행한다.
//프로퍼티 애니메이션 객체 생성 transition = UIPropertyTransition():
//애니메이션을 수행할 객체 속성 지정 .toPosition = {100, 200} .toScale = 1.5 .toOpacity = 0.5 .toColor = “red”
//애니메이션 추가 정보 (시간, 반복 횟수, 되감기 여부) .duration = 2.0 .repeat = 3 .rewind = true
//애니메이션 대상 객체 .target = obj
//경우에 따라 커스텀 인터폴레이터 적용 (코드 12 참조) myInterpolator = UICustomInterpolator():
transition.interpolator = myInterpolator
//애니메이션 재생 transition.play()
3. 필터 효과
본 절에서는 앞 절에서 살펴본 애니메이션 기법 외 애니메이션 응용 사례를 추가로 확인한다. 그중 4.6.3절 이미지 필터는 비주얼 효과를 보여주는 대표 사례로서 여기에 애니메이션을 결합한다면 UI 심미성을 더욱 높일 수 있다. 본 절에서는 4장에서 다루지 않았던 이미지 필터를 구현하고 애니메이션을 적용하는 과정을 학습함으로써 애니메이션에 대한 이해와 응용력을 다진다.
3.1 블러 필터
필터 효과의 대표로 꼽을 수 있는 블러는 이미지를 흐리게 하는 효과를 제공한다. 이는 마치 카메라 초점을 조절하는 것과 유사한데 배경 이미지나 특정 뷰를 흐리게 함으로써 일부 UI나 콘텐츠를 강조한다. 실제 iOS의 팝업(Popup) UI는 사용자가 팝업 콘텐츠에 집중할 수 있도록 팝업 배경에 블러 효과를 적용한다.
- 가우시안 필터 (Gaussian Filter)
그림 10: 표준 편차에 따른 가우시안 곡선
- 가우시안 함수를 이용하여 개입할 픽셀의 가중치 값을 테이블(블러 필터 커널)에 기록한다.
- 원본 영상의 픽셀을 순회하며 픽셀마다 커널의 가중치 값을 곱한다.
- 2번 단계에 개입하는 인접 픽셀에도 가중치 값을 곱한 후 현재 픽셀과 합산한다.
UIBlurFilter.power(Var val): kSize = (2 / 2) //커널 테이블 크기 3x3 고정 sigma = val //표준 편차
.kernel = Kernel[kSize * 2 + 1, kSize * 2 + 1] //중앙 픽셀을 위해 kSize + 1
/* 가우시안 함수를 이용한 가중치 계산 */ s = 2.0 * sigma * sigma for(y = -kSize; y <= kSize; y++) for (x = -kSize; x <= kSize; x++) r = x * x + y * y weight = exp(-(r / s)) / (PI * s) .kernel[y + kSize][x + kSize] = weight
UIBlurFilter implements UIFilter: /* * 블러 필터 * @p in: Pixel * @p coord: Point
*/
func(in, coord, ...): Pixel out //출력 픽셀 Pixel buffer[,] = .src.map() //블러를 적용할 이미지 Kernel kernel[,] = .kernel //커널 테이블 /* 가중치를 적용하여 인접한 픽셀과 합성 수행 예제는 3x3 커널 크기인 경우로 국한 */ if(.blurSize == 1) //메모리 범주를 벗어나지 않도록 예외처리 하는 코드는 생략 Pixel lt = buffer[coord.y - 1][coord.x - 1] //left top Pixel t = buffer[coord.y - 1][coord.x] //top Pixel rt = buffer[coord.y - 1][coord.x + 1] //right top Pixel l = buffer[coord.y][coord.x - 1] //left Pixel r = buffer[coord.y][coord.x + 1] //right Pixel lb = buffer[coord.y + 1][coord.x - 1] //left bottom Pixel b = buffer[coord.y + 1][coord.x] //bottom Pixel rb = buffer[coord.y + 1][coord.x + 1] //bottom right //픽셀 합성, 가중치를 곱한 픽셀값 합산 out = (lt * kernel[0][0] + t * kernel[0][1] + rt * kernel[0][2] + l * kernel[1][0] + in * kernel[1][1] + r * kernel[1][2] + lb * kernel[2][0] + b * kernel[2][1] + rb * kernel[2][2]) return out
obj = UIObject(): ... filter = UIBlurFilter(): .power = 1 //블러 단계를 1로 지정 obj.filters += filter //어떤 UI 객체에 블러 필터 적용
- 렌더 패스 (Render Pass)
그림 12: 필터 수행을 위한 2단계 렌더링
3.2 필터 애니메이션
일정 시간 동안 필터값을 조정하여 필터 애니메이션을 만들 수 있다. 대표로 블러 필터에 애니메이션 적용한다면 시간 경과에 따라 흐려지는 효과가 나타난다.
그림 13: 블러 애니메이션
UIObject content ... //블러 필터 추가 filter = UIBlurFilter(): .filters += filter //1초 동안 애니메이션 가동 animation = UIAnimation(1.0)
f = closure(UIAnimation animation, UIObject obj, Var progress):
filter = UIBlurFilter():
.power = 1 + 4 * progress //1초 동안 블러 단계는 1에서 5로 증가한다.
obj.filters += filter
animation.EventUpdated += {f, obj} animation.play()
4. 입력 이벤트
그림 14: 유저 인터렉션 수행 단계
사용자 상호작용(User Interaction)은 입력, 조건 처리, 기능 수행, 상태 변경 네 단계를 포함한다. 본 절에서는 사용자 입력을 받는 입력 처리하는 단계에 초점을 둔다.
4.1 입력 신호 전달
입력 신호는 여러 정보를 기록한다. 가령 어떤 장치에서 발생한 신호인지 구별하기 위한 ID, 신호가 발생한 시간 정보(Timestamp), 입력 신호의 화면 상 위치(Position), 키가 눌렸는지(Press/Release) 커서가 이동했는지(Move) 여부 등 정보가 이에 해당한다. 서버와 클라이언트 간 이벤트 전송은 비동기적이므로 클라이언트는 전달받은 신호를 입력 신호 큐(event queue)에 축적한다. 만약 UI 엔진이 입력 신호 송신을 전담하기 위한 스레드를 별도로 운용한다면 UI 앱의 메인루프는 입력 신호로 동작이 지연되는 것을 피할 수 있다. 이 경우 메인루프는 이벤트 대기 단계에서 입력 신호가 큐에 존재하는지 확인하고 있는 경우 이벤트 처리 과정으로 진입하면 된다. 그 후 입력 신호는 캔버스 또는 위젯 엔진, 필요에 따라 제스처 관리자(Gesture Manager) 등을 거친 후 최종적으로 대상 UI 객체까지 전달된다.
그림 16: UI 컨트롤로 입력 이벤트를 전달하는 과정
캔버스 엔진은 내부에서 관리하는 UIObject 객체 리스트로부터 이벤트를 받을 대상 객체를 찾아야 한다. (코드 21) 이는 객체 리스트를 순회하며 객체 영역(Bounding Box)과 이벤트 발생 위치를 비교하여 판단할 수 있다. 일반적으로 이벤트 발생 위치에 여러 객체가 중첩된 경우 최상단 객체가 이벤트 우선순위를 갖는다. 따라서 최상단 객체를 우선으로 이벤트를 호출한다. 만약 엔진에서 이벤트 전파(Propagation) 기능을 제공한다면 이벤트를 하단의 객체로 전달할지 말지 결정할 수 있다. (그림 17)
/*
* 캔버스 엔진 수준에서 이벤트 처리 작업 수행
* info는 UIEngine으로부터 전달받은 입력 이벤트 정보가 기록되어 있다.
*/ UICanvas.processEvent(info, ...):
/* 이벤트를 전파할 대상 객체 탐색
객체 목록은 레이어 순으로 정렬되어 있다고 가정 */
foreach(.objs, obj)
if(obj.processEvent(info, ...) == false) break
UIObject.processEvent(info, ...): //활성 객체인 경우에 이벤트 수행 (2.3절 참고) if(obj.activate == false) return true //이벤트 발생 위치가 오브젝트 영역 내에 위치하는가? if(intersects(obj.geometry, info.position) == false) return true /* 자식이 존재하는 경우 자식에게 먼저 이벤트 전달
자식 목록은 레이어 순으로 정렬되어 있다고 가정 */ foreach (obj.children, child) if(child.processEvent(info, ...) == false) break
//해당 이벤트 타입으로 등록된 콜백 함수 목록 List eventCbs = obj.eventTable[info.type] //콜백 함수 순차적 호출 foreach(eventCbs, func) func(...)
//true인 경우 다음 오브젝트로 입력 이벤트 전파 return obj.propagation
//객체의 이벤트 전파 여부를 위해 다음 인터페이스 제공 obj.propagateEvent = true
4.2 제스처
기본적으로 제스처 이벤트는 윈도우 서버로부터 전달받은 원본 입력 이벤트를 가공하고 인터페이스를 통해 이를 앱에게 제공하는 것을 목표로 한다. 이를 위해 UI 프레임워크는 이를 전담하는 제스처 모듈(UIGesture)을 구현하고 이를 UI 엔진에 결합할 수 있다. 이로써 UI 앱은 UI 컨트롤을 이용하여 제스처 이벤트를 등록하고 앱 시나리오에 맞는 기능 동작을 구현한다.
특수 경우로 제스처 입력을 수행해야 한다면 윈도우 서버에서 제스처 이벤트를 정의하고 처리하는 것도 가능하다. 예를 들면 모바일의 퀵패널(QuickPanel)을 활성화하는 기능이 이에 해당한다. 사용자는 화면을 수직으로 드래그(Drag)함으로써 퀵패널을 활성화한다. 퀵패널 역시 윈도우 클라이언트에 해당할 수 있으나 윈도우 서버와 긴밀하게 이벤트를 주고받기 위해서 윈도우 관리자 모듈(그림 22)로 설계할 수 있다.
//다음 UIGesture 설정은 앱에서 사용하는 제스처 기능에 전역적으로 영향을 미친다. //tabTimeOut을 0.5초로 지정 UIGesture.tabTimeout = 0.5
//zoom 인식을 위해 두 탭 간 최소 거리를 100픽셀로 지정 UIGesture.zoomDistanceTolerance = 100 //줌 제스처 발생 시 해당 줌 팩터만큼 뷰를 확대 또는 축소 f = closure(UIView obj, UIGestureZoomInfo info):
obj.scale = info.zoom
view = UIView(): .EventZoom += f ...
코드 24의 10번째 줄을 확인하면 줌 팩터 값을 사용자에게 제공하기 위해 UIGestureZoomInfo 파라미터를 콜백 함수에서 정의하는 것을 확인할 수 있다. 여기서 UIGestureZoomInfo는 UIGestureInfo의 확장 타입으로서 줌 정보를 추가로 가진 구조체 정보로 볼 수 있다. 실제 인터페이스 설계 방안은 각 디자인 방침에 따르면 된다.
제스처 커스터마이징 요소를 UI 시스템 환경 설정 데이터로 기술할 수 있다면 해당 UI 시스템을 이용하는 제품마다 그 특성을 쉽게 커스터마이징 할 수 있다. 이는 더욱 유연한 UI 프레임워크 시스템을 구축하는 데 도움을 준다.
5. 스레딩
2.2절에서 언급했듯이 60fps 앱 성능을 보장하기 위해서는 UI 엔진의 메인루프가 한 사이클마다 처리해야 하는 작업은 0.0167초 안에 끝나야 한다. 이 시간 동안 앱은 비즈니스 로직부터 렌더링 엔진의 드로잉 작업을 완수해야 한다. 예로 그림 6.2의 사용자 애니메이션 수행 함수가 어떤 작업 부하로 시간을 지연시킨다면 메인루프는 0.0167초 내로 장면을 완성할 수 없을 것이다. 같은 맥락에서, 이벤트 처리 과정 중 많은 양의 데이터를 주고받거나 연산 처리를 수행한다면 동일 문제는 발생한다. 또한 앱에서 구현한 어떤 함수가 많은 시간을 소모한다면 UI 엔진의 메인루프는 그만큼 동작이 지연되며 화면 갱신 또한 느려질 것이다. 이러한 이유로 메인 루프의 동작 수행이 지체되어 장면을 제때 만들지 못한다면 프레임 손실이 발생한다. 프레임 손실은 특히 애니메이션 시 두드러지는데 매끄럽지 않은 애니메이션 효과로 인해 사용자 경험 만족도는 하락한다. 따라서 애니메이션 중 프레임 저하가 발생하는 시나리오는 최적화의 우선순위 대상이다.
이와 같은 이유에서 UI 앱에서 많은 연산 작업이 필요한 경우 작업을 병렬로 처리하는 방법을 고려할 수 있다. 병렬 처리를 적용함으로써 가용한 다수의 컴퓨팅 유닛(CPU)을 적절히 활용하여 성능을 향상한다. 그뿐만 아니라 부하를 유발하는 작업을 별도의 스레드로 분리함으로써 메인루프의 지연을 방지한다. 핵심은 UI 엔진의 메인루프가 원활히 구동될 수 있어야 한다는 점에 있다.
우리는 병렬 처리의 기본이 되는 메커니즘으로서 스레드(Thread)를 언급하지 않을 수 없다. 현대의 프로그래밍에서 스레드는 원시적인 방법에 해당하지만, 운영체제에서는 병렬 처리의 근간이 되는 개념에 해당한다. 스레드는 유닉스(Unix)를 기반으로 하는 임베디드 시스템과 여러 운영체제에서 지원하기 때문에 호환성이 뛰어나 소프트웨어 개발에서 응용할 수 있는 방법의 하나로 채택한다. 다만 예측 불가한 동작 순서는 비결정적(Non-deterministic) 동작 결과를 초래하고 이는 디버깅을 어렵게 하지만 섬세한 설계를 바탕으로 스레드 동기화(Synchronization)와 같은 도구를 잘 활용한다면 안정적이고 효율적인 결과를 만들 수 있다. 특히 UI 프레임워크는 사용자가 쉽고 안정적으로 병렬화를 적용할 수 있도록 UI 엔진과 상호작용하는 스레드 동작과 편의 인터페이스를 제공해야 한다.
5.1 멀티 스레딩 전략
시스템과 통합된 UI 엔진을 설계한다면 멀티 스레딩 환경에서 주 스레드는 메인루프에 우선 배정한다. 메인루프를 주 스레드에 배정하면 메인루프를 프로세스의 라이프사이클과 동일시 할 수 있으며 UI 앱의 라이프사이클을 관장하기 유리해진다. 이 경우 사용자는 작업 스레드(Worker Thread)를 추가하여 메인 루프 외 어떤 작업을 병렬화 할 수 있다. 여기서 만약 작업 스레드의 수행 결과물이 UI에 반영되어야 한다면 작업 스레드와 주 스레드 간 공유 자원을 안전하게 접근할 수 있는 동기화(Synchronization)를 수행해야 한다. 동기화 방안으로는 임계 영역(Critical Section)이 있으며 이 구간만큼은 메인루프도 지정된 사이클을 중단하고 작업 스레드와 데이터 공유 작업을 수행한다. 여기서 임계 영역이란 여러 스레드가 공유 자원에 동시에 접근하면 안 되는 코드 일부를 의미한다. 한편 작업 스레드의 결과물이 UI에 의존성을 갖지 않는다면 사용자는 작업 스레드를 독립 프로시저(Procedure)로써 비교적 쉽게 운용할 수 있다.
그림 23에서 임계 영역을 지정하기 위해 UITask(작업 스레드)에서 begin_critical_section()과 end_critical_section()을 호출한 것에 주목한다. 이때 UIEngine은 두 호출 구간에서 메인루프를 대기 상태로 만들고 작업 스레드가 UI 공유 자원에 안전하게 접근하는 것을 보장한다.
5.2 태스크
앞선 설명을 바탕으로 UIEngine은 고수준 병렬 작업을 제공하기 위해 UITask(태스크) 기능을 제공할 수 있다. UITask는 사용자가 스레드를 직접 사용하지 않고도 병렬 작업을 쉽게 구축할 수 있는 추상화한 개념을 제공한다. UITask 핵심 메서드를 살펴보면 다음과 같다.
/* UITask는 UIEngine과 동기화를 수행하는 병렬(또는 직렬) 작업을 구축한다. 사용자는 작업
병렬화를 위해 UITask로부터 사용자 클래스를 확장한다. UITask는 run()을 통해 실행되고 작업 스레드에서 task()를 수행한다. task()가 종료되면 동기화 작업을 위해 메인루프로부터 end() 메서드를 호출한다. 따라서 end()는 작업 스레드가 아닌 주 스레드에 해당하므로 동기화 작업을 따로 수행할 필요가 없다. */ UITask: /* 실제 수행할 동기/비동기 작업을 구현한다. 비동기(async) 경우 task()는 작업 스레드에서 호출되고 동기(sync)의 경우 메인루프를 구동하는 주 스레드에서 호출된다. */ task(): /* task()가 끝나면 호출된다. end()에서는 작업 결과물을 공유 자원(UI 객체)에
반영한다. 또는 작업 후 리소스를 정리하는 작업을 수행할 수도 있다. */ end(): /* 스레드를 배정받고 task()르 실행한다. 필요하다면 메서드 인자나 컴파일러 내장 옵션을 통해 동기/비동기 수행을 결정할 수 있을 것이다. */ run():
/* 사용자는 비동기 작업을 수행하기 위해 UITask로부터 UserTask를 구현한다. */ UserTask extends UITask: /* UIImage는 캔버스 엔진에 종속된 공유 자원에 해당 동기화를 무시한다면 작업 스레드에서 해당 객체에 접근 시 캔버스 엔진을 수행하는 주 스레드와 불안정한 경합이 발생할 수 있다. */ UIImage img constructor(): //img 초기 설정 수행 ... /* 여기서 어떤 Heavy한 작업을 한다고 가정하자. */ override task(): /* 원격으로 어떤 대용량 이미지를 받아온다. 내려받기(Download)를 완수하면 task()를 종료한다. */ ... /* task() 완료 후 수행할 종료 작업 */ override end(): //내려받은 이미지를 이미지 객체로 출력 .img.path = “/tmp/downloaded.png”
마지막으로 사용자는 준비한 작업을 주 스레드에서 호출한다.
/* func()은 주 스레드에서 동작하는 어떤 함수다. */ func(): task = UITask(): .run()
그림 24: 작업 스레드 기반 UITask 수행 절차
그림 24에서 사용자 로직과 UI 엔진 즉, 메인루프는 주 스레드에서 절차적으로 수행되는 한편 태스크는 작업 스레드에서 수행된다. 그리고 UI 엔진은 두 스레드 간 작업 조율을 담당한다. 이를 위해 UI 엔진은 메인루프의 이벤트 처리 단계에 Task 요청 메시지를 해석하고(dispatch) 스레드 작업을 개시한다. (그림 25) UI 엔진은 사용자로부터 요청받은 작업을 수행할 스레드를 준비하고 작업을 실행하며 작업 스레드는 작업을 완수하면 UI 엔진으로 비동기 메시지를 보내어 작업 완료 사실을 알린다. 이후 메인루프 사이클을 수행하고 있던 UI 엔진은 다시 이벤트 처리 단계에서 해당 메시지를 확인하고 task.end()를 호출하여 사용자가 안전하게 작업을 완료할 수 있도록 한다.
그림 25에서 태스크 메시지(Task Msg)는 일반 이벤트로서 취급한다. 이는 그림 16의 사용자 입력 신호를 전달하는 방법과 동일하다. 하지만 실제 UI 엔진 운용 중 필요한 이벤트와 메시지는 복잡하고 다양하다. 따라서 이벤트를 더욱 정교하게 처리하기 위해서는 이벤트 형식마다 최적화된 이벤트 수행 루틴을 구축하는 것을 고려할 수 있다.
5.3 크리티컬 세션
앞서 살펴본 바에 의하면, UITask를 통해 어떤 작업이 완성하고 그 작업 결과물은 end() 메서드를 활용하면 단발성을 띈 작업을 병렬로 수행하는 것은 문제되지 않을 듯 하다. 하지만 비동기 작업을 진행하면서 작업 결과물을 주기적으로 반영해야 한다면 동기화 역시 주기적으로 요청해야 한다. 이를 위해 UIEngine에서 임계 영역을 지정할 수 있는 방법을 추가로 고려해 볼 수 있다.
override UserTask.task(): /* 여기서 어떤 Heavy한 작업을 거쳐 임의 수의 이미지를 실시간으로 생성한다고 가정하자. 생성된 이미지는 즉시 이미지 객체를 통해 화면에 출력해야 한다. */ repeat (infinite) Pixel bitmap[] = generateImage(...) /* beginCriticalSection()을 호출하면 메인루프가 동작을 중단하여 작업 스레드에서 공유 자원에 안전하게 접근할 수 있도록 도와준다. 동기화 작업이 끝나면 endCriticalSection()를 호출하여 메인루프가 재개하도록 한다. */ UIEngine.beginCriticalSection() .img.load(bitmap) UIEngine.endCriticalSection()
그림 23과 같이 beginCriticalSection()과 endCriticalSection() 사이는 메인루프가 동작을 멈추는 구간으로서 주 스레드가 공유 자원에 접근하지 못하도록 방지하는 역할을 수행한다. 작업 스레드에서 UIEngine.beginCriticalSection()을 호출하면 주 스레드로 스레드 동기화 요청 메시지를 보낸 후 작업 스레드는 그 결과를 받기 위한 모니터링 상태(또는 대기 상태)로 전환한다. 이 후 지정된 메인루프 사이클을 돌던 주 스레드는 이벤트 처리 단계에 도달 시 요청한 메시지를 해석한다. 이 후 주 스레드는 요청한 대로 메인루프의 동작을 일시 중지하고 작업 스레드가 임계 영역 구간을 수행할 수 있도록 동작 재개 요청 메시지를 보낸다. 이 후 주 스레드는 endCriticalSection()이 불릴 때까지 모니터링(대기) 상태에 빠지며 주 스레드로부터 UI 객체에 접근하는 일이 없도록 방지한다. 한편, 모니터링 상태에 있던 작업 스레드는 주 스레드로부터 동작 재개 메시지를 전달 받고 그 즉시 대기 상태에서 깨어나 이 후 작업을 진행한다. 이 후 작업 스레드는 endCriticalSection()을 호출하면서 주 스레드와 동기화 작업을 동일한 방식으로 수행한다.
beginCriticalSection()과 endCriticalSection() 구간은 임계 영역으로서 주 스레드는 대기 상태로 존재한다. 그로 인해 메인루프가 동작을 멈추며 화면 갱신은 지연된다. 따라서 매끄럽게 동작하는 UI 앱을 구현하기 위해서는 임계 영역을 최소로 지정해야 한다.
5.4 태스크 스케줄링
이론상 하나의 프로세스는 가용한 물리적 스레드(Physical Thread) 수와 동일한 수의 논리적 스레드(Logical Thread)를 운영하는 것이 이상적이다. 물리적 스레드보다 많은 논리적 스레드를 생성한다면 스레드 전환 작업에 시간을 낭비하여 오히려 성능 하락 요소로 작용할 수 있다. 따라서 시스템은 최적의 스레드 운용을 위한 스레드 관리를 고려해야 한다. 만약 태스크마다 스레드를 생성한다면 스레드 과부하가 발생할 수 있다. UI 엔진은 작업 스레드 수에 제한을 두고 다수의 태스크를 효율적으로 처리할 수 있어야 한다.
- 스레드 풀 (Thread Pool)
6. 정리하기
애니메이션은 UI 앱을 보다 풍성하게 해주는 핵심 기능으로서 UI 엔진에서 반드시 다뤄야 하는 요소 중 하나이다. 이번 장에서 우리는 애니메이션 이해와 사용자가 애니메이션을 구현하는 데 있어서 필수 요건에 대해 살펴보았다.
기본적으로 애니메이션은 연속된 장면을 한 장씩 출력함으로써 표현한다. 이를 위한 메커니즘을 프레임 애니메이션을 통해 살펴보았다. 메인루프를 기반으로 애니메이션 프레임 번호를 결정하고 사용자가 애니메이션 장면을 결정하기 위한 사용자 인터페이스도 살펴보았다. 그뿐만 아니라 부드러운 애니메이션 표현하기 위한 프레임 시간 계산 및 제어 방법을 살펴보았고 애니메이션 가속 방식과 이를 커스텀 할 수 있는 메커니즘도 함께 살펴보았다.
여러 애니메이션 기법 중 UI 객체의 속성에 변화를 둔 프로퍼티 애니메이션에 대해서 살펴보았다. 이러한 애니메이션 동작을 기반으로 필터 애니메이션을 적용해 봄으로써 애니메이션 적용 사례에 대한 이해도를 높였다. 추가로 사용자 상호 작용을 위해 사용자 입력 이벤트 처리 과정을 살펴보았고 나아가 제스처 인식의 고수준 이벤트 제공 방안도 살펴보았다.
마지막으로 스레드 풀을 적용한 고수준 태스크 스케줄링과 비동기 작업 수행 메커니즘을 살펴보았다. 이 과정에서 UI 엔진의 메인루프와 함께 여러 작업을 동시에 수행할 수 있는 고성능 UI 엔진을 구축 방안도 함께 이해할 수 있었으며 이러한 병렬 처리 기반으로 원활한 애니메이션 엔진을 구축할 수 있음을 배울 수 있었다.