본문으로 건너뛰기

곡선 궤적 애니메이션 원리

무료2017-01-07#JS#CSS曲线动画#CSS轨迹动画#CSS沿曲线运动#JS曲线动画#JS抛物线动画

애니메이션 라이브러리는 대개 다양한 easing 효과를 제공하지만, 곡선 운동을 구현하려면 약간의 팁이 필요합니다.

1. 애니메이션 함수

애니메이션은 시간에 따른 변위의 함수입니다: s = f(t)

독립 변수는 t, 종속 변수는 s입니다. 물체의 변위가 시간에 따라 변하는 것이 바로 애니메이션입니다. 예를 들어:

// 알려진 값
var property = 'marginLeft';
var s0 = 100;   // 시작점
var s1 = 200;   // 끝점
var duration = 1000;

// 문제 조건에 따라
var S = s1 - s0;    // 총 변위
var T = duration;   // 총 시간

// 임의의 시각 t에 대응하는 변위 구하기
var t0 = +new Date();
var tick, interval = 1000 / 60;
setTimeout(tick = function() {
    var t = +new Date() - t0;
    // 완성도
    var p = Math.min(t / T, 1);
    // t 시점의 시작점 대비 변위 s
    var s = S * p;
    document.body.style[property] = s0 + s + 'px';

    if (p !== 1) setTimeout(tick, interval);
}, interval);

marginLeft100px에서 200px까지 균일하게 변하며, body는 먼저 오른쪽으로 100px 건너뛴 다음 1초 동안 오른쪽으로 100px 등속 이동합니다.

이러한 애니메이션을 구현할 때 마주하는 유일한 문제는 총 변위 S와 총 시간 T를 알 때, 임의의 시각 t에 대한 시작점 대비 변위 s를 구하는 것입니다.

우리는 등속 직선 운동을 구현했는데, 겉보기에는 s = vt를 사용하지 않은 것 같지만 실제로는 사용되었습니다:

s = v * t
  = (S / T) * t
  = S * (t / T)
  = S * p

애니메이션 함수는 s = f(t)이므로 식 안에 v가 없어야 합니다. v알려진 양으로 치환해야 하는데, 완성도가 p = t / T이므로 애니메이션은 완성도에 대한 변위의 함수이기도 합니다.

2. 등가속도 운동

같은 원리로 등가속도 운동 변위 공식에서 va를 치환하여 시간 t에 대한 변위 s의 함수를 얻습니다.

등가속

변위 공식:

// v0 = 0일 때, 미지수는 a 하나뿐입니다.
s = 1/2at^2

총 시간 T, 총 변위 S, 완성도 p = t / T를 알 때, 임의의 시각 t에 대한 시작점 대비 변위 s를 구하면:

// 끝점에서
S = 1/2 * a * T^2
// 따라서
a = 2S / T^2
// 임의의 시각
s = 1/2 * a * t^2
  = 1/2 * (2S / T^2) * t^2
  = 1/2 * 2S * (t^2 / T^2)
  = S * p^2

등감속

변위 공식:

// 두 개의 미지수 v0와 a를 포함합니다.
s = v0t - 1/2at^2

총 시간 T, 총 변위 S, 완성도 p = t / T를 알 때, 임의의 시각 t에 대한 시작점 대비 변위 s를 구하면:

// 1. 역방향 등가속으로 v0 구하기
// 끝점에서
S = 1/2 * a * T^2
// 따라서
a = 2S / T^2
v0 = aT = 2S / T^2 * T = 2S / T
// 2. 임의의 시각
s = v0 * t - 1/2 * a * t^2
  = (2S / T) * t - 1/2 * (2S / T^2) * t^2
  = 2S * (t / T) - S * (t^2 / T^2)
  = 2S * p - S * p^2
  = S * p * (2 - p)

3. 곡선 운동

단순한 곡선 운동은 직선 운동으로 분해할 수 있습니다. 예를 들어 사인 함수 y = sinx는 다음과 같이 분해됩니다:

// x축 등속 직선
x = S * p = 2PI * p
// y축 sinx
y = sinx = sin(2PI * p)

평사(horizontal projection) 운동은 다음과 같이 분해됩니다:

// x축 등속 직선
x = S * p = X * p
// y축 등가속
y = S * p^2 = Y * p^2

포물선의 왼쪽 절반은 왼쪽으로 던져진 평사 운동의 역운동으로 볼 수 있습니다:

// x축 등속 직선
x = S * p = X * p
// y축 등감속
y = S * p * (2 - p) = Y * p * (2 - p)

원운동은 약간 특수합니다. 대수 방정식 (x - a)^2 + (y - b)^2 = r^2에서 x, y를 계산할 때 부호 문제가 발생하므로(번거롭지만 가능합니다), 매개변수 방정식을 고려합니다:

// 원 위의 임의의 점에 대해
sinθ = y / r, cosθ = x / r
// 매개변수 방정식 도출
x = a + r * cosθ, y = b + r * sinθ

// 원점이 (0, 0)일 때
x = r * cosθ = r * cos(θ * p), y = r * sinθ = r * sin(θ * p)

각도 [0, 2PI]가 균일하게 변함에 따라 x, y가 각도에 맞춰 변합니다.

P.S. 원운동을 극좌표로 설명하는 것은 다소 억지스러울 수 있습니다 (r(θ) = r로 원심을 설정하고 [0, 360]까지 균일하게 rotate하는 방식은 transform이 없던 시대에 위치를 어떻게 계산했을까요?)

4. Easing 함수

위에서 도출한 공식들을 비교해 보면:

s = S * p               // 등속
s = S * p^2             // 등가속
s = S * p * (2 - p)     // 등감속
s = S * cos(2PI * p)    // cos
s = S * sin(2PI * p)    // sin

총 변위 S는 일정하고 뒷부분만 다릅니다. 따라서:

var easings = {
    linear: function(p) { return p; },
    acceleration: function(p) { return p * p; },
    deceleration: function(p) { return p * (2 - p); },
    sin: function(p) { return Math.sin(2 * Math.PI * p); },
    cos: function(p) { return Math.cos(2 * Math.PI * p); }
}

이러한 easing 함수들은 p를 보정하는 데 사용됩니다. 따라서 애니메이션은 다음과 같아야 합니다:

// 임의의 시각 t에 대응하는 변위
st = s0 + S * easing(p)
// 즉
// 현재 값 = 초기 값 + totalDelta * easing 함수로 보정된 완성도

p = t / T이므로 실제로는 easingt에 작용하며, 이를 타이밍 함수(timingFunction)라고도 부릅니다.

애니메이션 라이브러리들은 모두 이 방식을 사용합니다. 예를 들어 jQuery는:

// from https://github.com/jquery/jquery/blob/2d4f53416e5f74fa98e0c1d66b6f3c285a12f0ce/src/effects/Tween.js
jQuery.easing = {
    linear: function( p ) {
        return p;
    },
    swing: function( p ) {
        return 0.5 - Math.cos( p * Math.PI ) / 2;
    },
    _default: "swing"
};

Velocity는:

// from https://github.com/ayqy/velocity-1.4.1/blob/master/velocity.js
Velocity.Easings = {
    linear: function(p) {
    // 선형, 완성도를 그대로 반환
        return p;
    },
    swing: function(p) {
    // 양 끝은 느리고 중간은 빠름, cos이 +1에서 -1로 변하며 중간 기울기가 가장 커서 변화가 가장 빠름
        return 0.5 - Math.cos(p * Math.PI) / 2;
    },
    /* Bonus "spring" easing, which is a less exaggerated version of easeInOutElastic. */
    spring: function(p) {
    // easeInOutElastic의 완화된 버전
        return 1 - (Math.cos(p * 4.5 * Math.PI) * Math.exp(-p * 6));
    }
};

마찰력(spring), 중력(bounce) 등의 물리 효과나 자주 쓰이는 베지어 곡선 기반의 缓动 함수 시리즈, step 효과 등 복잡한 easing들도 모두 같은 원리로 완성도를 보정하여 속도를 제어합니다.

5. 온라인 데모

Velocity의 커스텀 easingRedirects를 사용하여 이러한 곡선 궤적을 구현할 수 있습니다. 예를 들어:

// 커스텀 缓动 함수
// 등가속
Velocity.Easings.acceleration = function (p, opts, tweenDelta) {
    return p * p;
};
// 등감속
Velocity.Easings.deceleration = function (p, opts, tweenDelta) {
    return p * (2 - p);
};

// 커스텀 애니메이션 효과
Velocity.Redirects['throw-h'] = function(element, options, elementsIndex, elementsSize, elements, promiseData) {
    Velocity(this, {
        translateX: [300, 'linear', 0],
        translateY: [300, 'acceleration', 0]
    }, options);
};

// 실행
Velocity(document.body, 'throw-h', 3000);

자세한 내용은 데모를 참조하세요: http://ayqy.net/temp/curve-path-animation.html

데모 과정에서 Velocity는 완성도가 1일 때 강제로 끝점 값을 할당하는데, 이는 sin과 같은 시나리오에서 문제가 발생할 수 있습니다. 소스 코드는 다음과 같습니다:

else if (percentComplete === 1) {
// 완료됨, 끝점이 정확하도록 수동으로 값 할당 (연산 정밀도 영향 방지)
//!!! sin 등의 경우 끝점이 0이므로 수동 할당을 해서는 안 됩니다.
//!!! 강제 할당은 오류를 발생시킵니다.
    /* If this is the last tick pass (if we've reached 100% completion for this tween),
     ensure that currentValue is explicitly set to its target endValue so that it's not subjected to any rounding. */
    // currentValue = tween.endValue;
    currentValue = tween.currentValue;
}

해결 방법은 easing 함수를 신뢰하는 것(위 부분을 제거)입니다. 끝점에서도 easing을 통해 현재 값을 계산하도록 합니다. 이 방식의 단점은 연산 정밀도 문제가 발생할 수 있다는 점입니다. 예를 들어 sin2PI는 0이지만 계산 결과는 0이 아닌 아주 작은 값이 될 수 있습니다. 하지만 실제로는 큰 영향이 없습니다.

참고 자료

댓글

아직 댓글이 없습니다

댓글 작성