자바스크립트와 애니메이션 개요
일반적인 어플리케이션 개발과 달리, 애니메이션 구현은 접근 방식은 조금 다른 측면들이 많다.
기본적으로 애니메이션은 하나의 페이지를 연속된 순서로 그리는 특징이 있는데, 이 때문에 setTimeout 과 같이 일정 시간의 간격을 둔 채로 렌더링을 하는 것이 필요하다.
setTimeout(()=>{ dom.innerHTML = 일_페이지(); }, 100 * 1);
setTimeout(()=>{ dom.innerHTML = 이_페이지(); }, 100 * 2);
setTimeout(()=>{ dom.innerHTML = 삼_페이지(); }, 100 * 3);
...
setTimeout(()=>{ dom.innerHTML = 마지막_페이지(); }, 100 * N);
JavaScript
< 이와 같이 페이지를 일정 간격동안 실행 한다. >
하지만 위 코드와 같은 간격(100ms)는 부드러운 애니메이션이라고 느끼기에는 너무 큰 값이다.
이에 대해 MDN에서는 다음과 같이 설명한다.
Users expect all interface interactions to be smooth and all user interfaces to be responsive. Animation can help make a site feel faster and responsive, but animations can also make a site feel slower and janky if not done correctly. Responsive user interfaces have a frame rate of 60 frames per second (fps)
. While it is not always possible to maintain 60fps, it is important to maintain a high and steady frame rate for all animations.
즉, 1초에 60프레임이 새로 그려져야 유저가 애니메이션이라고 느낀다는 것이다.
이를 계산하면 1000ms/60 로 약 16.6ms 에 해당한다. 물론 매번 정확히 16.6ms마다 새로 그리진 못하고 최대한 이 수치 혹은 그 이하로 실행하는 것을 목표로 해야한다.
그런데 여기서 문제가 발생하는데 16.6ms 의 틈이라는 것이 사람의 시신경 뿐 아니라 컴퓨터에게도 아주 작은 수치라는 것이다. 컴퓨터가 함수 하나의 연산을 마치는 시간이 16.6ms 이하라는 수치보다 커지게 되면 이 순서는 언제든 무너질 수 있다는 것이다.
이 같은 상황에서 렌더링이 연산보다 느려지면서 깜빡임(Flicker)을 발생시키거나 이전 좌표와 새로운 좌표를 통해 선을 그리거나 하는 경우에는 뒤죽박죽인 그림을 그리게 되는 전단(Shear) 현상이 발생할 수 있다.
캔버스 API 와 RequestAnimationFrame
Canvas API는 HTML의 <canvas> 엘리먼트를 통해 그래픽을 그리기 위한 수단을 제공한다. 그리고 연속된 그래픽을 통해 애니메이션, 게임 그래픽, 데이터 시각화, 실시간 비디오 처리등에 사용된다.
Canvas를 통해 간단한 선을 그려보자.
(캔버스 API에 대한 예시는 MDN의 튜토리얼에 잘 나타나 있으니 굳이 해당 글에서는 설명하지 않습니다. )
해당 코드에서는 (50, 50) 부터 (50, 100) 까지 y축으로 총 50의 길이를 가지는 선을 그린다.
만약 이를 애니메이션처럼 그린다고 한다면 (50,50) , (50, 51) , (50, 52) … ,(50, 100) 와 같은 순으로 매번 16.6ms로 lineTo를 증가하며 실행해야 할 것이다.
이를 코드로 나타내면 다음과 같다.
코드는 정상적으로 동작하나 위 코드는 깜빡임(Flicker) 혹은 전단(Shear) 현상이 발생할 수 있는 잠재적인 이슈가 있다.
이와 같은 문제를 해결하기 위해서는 두 가지 조건이 필요하다.
1.
이전 함수 이후에 다음 함수가 실행되는 것을 완전히 코드로 보장해야 한다.
2.
싱글 스레드인 브라우저의 한정된 자원을 최대한 효율적으로 사용해야 한다.
javascript는 이 문제를 해결 하기 위해서 setTimeout 과 setInterval 외에 requestAnimationFrame 라는 WEB API 를 제공한다.
requestAnimationFrame 는 브라우저에게 수행하기를 원하는 애니메이션을 알리고 다음 리페인트가 진행되기 전에 해당 애니메이션을 업데이트하는 함수를 호출하게 한다.
여기서 주목할 만한 점은 requestAnimationFrame 라는 이름이다. request 라는 단어 자체가 우리가 직접 설정하기 보다는 브라우저 엔진에 맡긴다는 어감이 강한데 실제로 그러하다. 모니터의 주사율이나 스레드의 상황등을 체크하여 가장 적합한 프레임으로 처리할 수 있게 돕는다.
다만 이 때, 반복문을 통한 가중치를 통한 지연(100 * i)등은 불가능한데, 때문에 실행할 콜백 내에서 해당 콜백을 재귀 호출하면서 다음 프레임을 연산하는 방식을 사용한다.
function draw() {
// ...logic
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
JavaScript
개선한 코드는 다음과 같다.
원형 역시 마찬가지로 Angle을 증가 시키면서 애니메이션을 그릴 수 있다.
이 때, Angle의 단위가 radians 이기 때문에 각도로 계산하려면 radians = (Math.PI/180)*degrees 와 같은 계산식을 활용할 수 있다.
let degrees = 0;
function draw() {
// ...생략
context.arc(50, 50, 50, 0, (Math.PI / 180) * degrees++);
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
JavaScript
곡선과 애니메이션
앞서 본 선과 원 그 외 사각형등은 그리는 것이 어렵진 않다.
하지만 곡선은 계산 방법이 직관적이진 않아 처음에는 꽤 난해하게 다가온다.
베지에(Bezier) 곡선
Canvas API에서 곡선을 그리는 방식은 Bezier Curve 라는 것을 이용한다.
베지에 곡선은 P 가 N개 만큼 있을 수 있으며, 각각을 N-1차 베지에 곡선이라고 부른다.
위는 2차 베지에 곡선이며 시작점인 P0, 제어점인 P1 그리고 종료점인 P2 로 이루어져 있다.
각각의 점들은 연결된 점으로 시간에 따라 이동하게 되며 각각의 선들이 이동하는 경로를 연결한 것이 그리고자 하는 곡선이 된다.
시간에 따른 각각의 점 P는 다음과 같은 식으로 정의 된다. 이 때 A, B, C 는 각각 시작점, 제어점, 종료점이다.
Canvas API에서는 2차 베지에 곡선을 그리기 위해서는 현재 위치 (x, y) 로부터 quadraticCurveTo(조절점 x, 조절점 y, 종료점 x, 종료점 y) 을 통해 그릴 수 있다.
즉 곡선이 애니메이션처럼 그려지게 만들기 위해서는 시작점을 제외한 두 점에 대해 위 식을 적용하여 t에 가중치로 주어서 그릴 수 있다.
// nx = 시간 가중치를 받는 현재 점 x
const nx = Math.pow(1.0 - t, 2) * A + 2.0 * (1 - t) * B + Math.Pow(t, 2) * C
JavaScript
기존처럼 기준점인 시작점은 그대로 두고 목표점을 향해 가중치를 부여하며 증가시키면 애니메이션을 구현 할 수 있을 것 같지만, 다른 도형과 달리 곡선은 N 개의 조절점을 추가로 계산해주어야 한다.
이를 위해 Parameterized Linear Interpolation 라는 방법을 이용한다. Linear Interpolation(선형 보간)은 애니메이션에서 특히 자주 사용 되는 데, 연속된 데이터 혹은 두 점이 있을 시에 그 사이의 값을 예측하는 방법이다.
즉, P(t0)1 와 P(t1)2를 통해서 선형보간을 하여 조절점을 구할 수 있다.
const nt0x1 = (1-t0)*P0 + t0*P1;
const nt0x2 = (1-t0)*P1 + t0*P2;
const n_ct = (1-t1)*nt0x1 + t1*nt0x2;
JavaScript
식을 바탕으로 베지에 곡선을 그리기 위한 코드는 다음과 같다.
function drawBezier(ctx, x0, y0, x1, y1, x2, y2, t0, t1) {
ctx.beginPath();
if (0.0 == t0 && t1 == 1.0) {
ctx.moveTo(x0, y0);
ctx.quadraticCurveTo(x1, y1, x2, y2);
} else if (t0 != t1) {
const A_x =
Math.pow(1.0 - t0, 2) * x0 +
2.0 * t0 * (1 - t0) * x1 +
Math.pow(t0, 2) * x2,
A_y =
Math.pow(1.0 - t0, 2) * y0 +
2.0 * t0 * (1 - t0) * y1 +
Math.pow(t0, 2) * y2;
const C_x =
Math.pow(1.0 - t1, 2) * x0 +
2.0 * t1 * (1 - t1) * x1 +
Math.pow(t1, 2) * x2,
C_y =
Math.pow(1.0 - t1, 2) * y0 +
2.0 * t1 * (1 - t1) * y1 +
Math.pow(t1, 2) * y2;
const B_x = lerp(lerp(x0, x1, t0), lerp(x1, x2, t0), t1),
B_y = lerp(lerp(y0, y1, t0), lerp(y1, y2, t0), t1);
ctx.moveTo(A_x, A_y);
ctx.quadraticCurveTo(B_x, B_y, C_x, C_y);
}
ctx.stroke();
ctx.closePath();
}
function lerp(v0, v1, t) {
return (1.0 - t) * v0 + t * v1;
}
JavaScript
애니메이션으로 동작하는 코드는 아래와 같다.
둘 이상의 요소를 합성한 애니메이션
지금까지는 하나의 애니메이션만을 실행시켜보았다.
하지만 복잡한 애니메이션을 그려야 한다면, 둘 이상의 도형을 합쳐야 하는 경우가 발생한다.
위와 같은 글씨를 그리거나 간단한 이모티콘을 그린다고 가정해보자.
하 를 그리기 위해서는 네 개의 선과 하나의 원이 필요하다. 이 같은 경우에 ㅇ 부분이 타원이기 때문에 총 네개의 곡선을 합쳐서 하나의 원을 그려야 한다.
일 경우는 많은 곡선과 선으로 이루어져 있다.
이러한 요소들이 순서대로 동작해야 하기 때문에 각각의 요소들 역시 순서를 지키며 렌더링이 실행되어야 한다.
이를 위해서는 콜백을 활용하여 명령 및 가중치와 같은 값들은 그림을 렌더하기 위한 함수로 부터 제어권이 역전되어야 한다.
const commands = [
{
type: TYPE.VOID,
run: () => {
context.beginPath();
},
},
{
type: TYPE.VOID,
run: () => {
context.moveTo(50, 50);
},
},
{
type: TYPE.LINE,
s_x: 50,
g_x: 70,
s_y: 50,
run: function (delta) { // 제어권을 바깥으로 가져와 정의하고
// drawing은 내부에서 실행한다.
context.lineWidth = 7;
context.strokeStyle = "#333";
context.lineTo(this.s_x + (this.g_x - this.s_x) * delta, this.s_y);
context.stroke();
},
duration: 150,
},
{
type: TYPE.VOID,
run: () => {
context.moveTo(40, 62);
},
},
...
]
JavaScript
이렇게 역전된 제어건을 바탕으로 애니메이션을 그리는 로직에서는 현재 실행중인 명령어가 종료되면 다음 명령어를 계속해서 실행해야 하기 때문에 다음 명령어를 실행하기 위한 코드를 넘겨주어야 한다.
const drawing = (command, next) => {
if(처리_완료) {
next();
} else {
다음_프레임_실행();
}
}
const runCommand = (commands) => {
if (!commands.length) {
return;
}
const command = commands.shift();
// 애니메이션이 아닌 이동과 같은 명령은 곧바로 다음 명령을 실행 시킨다.
if (command.type == TYPE.VOID) {
command.run();
runCommand(commands);
} else {
// 애니메이션인 경우에는 종료 시점에 실행시킬 함수를 넘긴다.
requestAnimationFrame(drawing(command, () => runCommand(commands)));
}
};
JavaScript
순서는 이로써 지킬 수 있지만, 여러 도형을 합쳐 무언가를 그리는 것은 마냥 싶지는 않다. 특히
와 같은 곡선을 그릴때는 분명 어렵다.
이런 경우일 수록 정확하게 계산하여 그려야 하는 것은 분명 하다.
하지만 팁으로 오차 조정등을 위해서 해당 익스텐션을 다운 받아 사용하면 편리하다.
좌측의 이미지처럼 브라우저에 불투명하게 사진을 덧댈수 있다.
패턴과 랜덤 요소를 활용한 애니메이션
우연한 상황에 패턴이 더해지는 것으로 예술 작품이 탄생하는 경우가 있다. 그리고 우리는 코드를 이용함으로써 쉽게 패턴을 반복하는 것이 가능하다.
이번 장에서는 3차 베지에 곡선과 이를 활용한 애니메이션을 패턴과 랜덤을 통해서 그려보고자 한다.
3차 베지에 곡선은 두 개의 control-point 를 통해서 곡선을 나타낸다.
이 때 위의 이미지 처럼 시작점과 끝점이 고정되어 있어도 두 개의 control-point 가 조절됨에 따라 곡선이 물결침을 확인 할 수 있다.
이를 이용하게 되면 다음과 같은 애니메이션을 그릴 수 있다.
일단 기존과 달리 모든 화면에 꽉 차게 선을 그려야 하기 때문에 캔버스의 크기를 window 의 사이즈로 맞추는 작업이 필요하다.
const setCanvasSize = (canvas) => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
window.addEventListener("resize", () => {
setCanvasSize(canvas);
});
JavaScript
캔버스가 설정됬다면 context 를 활용하여 베지에 곡선을 그릴 차례이다.
const cx1 = 가중치_비율 * window.innerWidth;
const cx2 = 가중치_비율 * window.innerWidth;
const curveParam = {
sx: 0,
sy: 0,
cx1,
cy1: 0,
cx2,
cy2: window.innerHeight,
ex: window.innerWidth,
ey: window.innerHeight,
};
drawCurve(ctx, curveParam);
const drawCurve = (ctx, { sx, sy, cx1, cy1, cx2, cy2, ex, ey }) => {
ctx.strokeStyle = "black";
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.bezierCurveTo(cx1, cy1, cx2, cy2, ex, ey);
ctx.stroke();
};
JavaScript
여기서 각각의 좌표에 대해 설명하자면 시작점과 끝점은 모두 0,0 와 화면 크기, 화면 크기 로 고정되어 있다.
그리고 출렁이는 효과를 만들기 위해서 control-point 의 y 축은 고정된 채로 x 축만 좌우로 이동하며 커브를 그린다.
이 때 가중치는 증감 수치는 어찌됐든 좋지만, 중요한 점은 가중치 자체는 선형적으로 증가하고 감소해야 한다.
const updateWeight = (weight) => {
const acc = Math.random() * (i + 1) * speed;
newWeight = (weight + acc);
};
JavaScript
하지만 이것만으로는 난수를 통해 선형적으로 증가하는 애니메이션을 부드럽게 표현 하기는 어렵다. 이를 해결하기 위해 Perlin Noise 라고 불리는 알고리즘을 이용한다.
Wave는 실제로 각도에 의해서 결정되므로 degree 를 단위로 표현할 수 있다.
반대로 조회할 때는 라디안 값으로 바꾸어야 하므로 wave / 180 * PI 를 계산한다.
그리고 중요한 포인트 중 하나는 가중치는 양수일 뿐만 아니라 음수 값도 표현해야 한다. 즉 -1 ~ 1 의 값을 표현해야 하는데, 이 때 sin 함수를 이용하면 이를 해결 할 수 있다.
코드로 나타내면 다음과 같다.
const createWaveNoise = (len) => {
const waves = [];
for (let i = 0; i < len; i++) {
waves.push(Math.random() * 360);
}
return waves;
};
const getWaveNoise = (waves) => {
const blendedWaveNoise = waves.reduce((acc, cur) => {
return acc + Math.sin((cur / 180) * Math.PI);
}, 0);
return (blendedWaveNoise / waves.length + 1) / 2;
};
JavaScript
업데이트의 경우에는 기존과 크게 다르지 않지만 각도를 사용하였기 때문에 가중치가 360보다 클 경우에는 그 나머지를 이용하게 만든다.
const updateWaveNoise = (waves) => {
waves.forEach((wave, i) => {
let r = Math.random() * (i + 1) * config.waveSpeed;
waves[i] = (wave + r) % 360;
});
};
JavaScript
전체 동작은 아래와 같다.
마치며
애니메이션은 비동기, 수학, DOM등을 극한으로 활용해야 하는 자바스크립트의 낭만이다.