요약: 이 글은 라이브러리를 사용하지 않고 수학과 물리를 이용하여 카카오페이의 인터랙티브 웹 애니메이션을 개발한 경험을 공유합니다. 디온은 눈 내리는 효과, 트리 곡선 그리기, 전구 흔들기 등 다양한 애니메이션 기법을 소개하며, 이 과정에서 선형 보간법, 3차원 베지어 곡선, 뉴턴의 운동 법칙 등을 활용했습니다. 라이브러리 없이 직접 개발하면 문제 해결 능력을 키우고, 창의적으로 개발하는 데 도움이 됩니다. 나아가 라이브러리도 더 잘 활용할 수 있다는 점을 강조합니다.
시작하며
안녕하세요, 카카오페이 채널FE파트 디온입니다. 채널FE파트에서 프론트엔드 개발을 하고 있으며 특히 인터랙티브 한 웹 애니메이션에 관심이 많습니다. 인터랙티브 웹 애니메이션이란 사용자와 상호 작용이 가능한 콘텐츠입니다. 사용자가 콘텐츠와 상호 작용할 수 있는 동적인 화면이 유저들에게 매력적인 요소와 시각적 흥미를 더해 오랫동안 웹 사이트를 기억할 수 있게 합니다.
웹 애니메이션에 관심을 갖게 된 계기는 우연히 접하게 된 인터랙티브 디벨로퍼라는 김종민 님의 책이었습니다. 라이브러리만으로는 만들 수 없는 애니메이션을 오로지 수학과 물리를 이용해 구현할 수 있다는 것에 감탄했습니다. 그때부터 라이브러리 사용 없이 서비스 개발을 진행해 왔고 그 덕분에 표현하고자 하는 UI의 범위가 더 넓어졌습니다. 대표적인 것이 펀드 한눈에 보기라는 서비스였습니다. 어떠한 라이브러리에서도 해당 UI를 제공하지 않아 처음으로 라이브러리 없이 개발하게 되었습니다.
이런 경험을 바탕으로 작년 연말에 진행한 카카오페이 프로모션 트리를 밝혀줘에서 일반적으로 웹에서 잘 볼 수 없었던 애니메이션을 개발하게 되었습니다. 본 포스팅에서는 라이브러리 사용 없이 수학과 물리를 이용하여 웹 애니메이션의 개발 과정에 대해 이야기해보려고 합니다. ‘트리를 밝혀줘’에서는 총 4가지 애니메이션이 있습니다. 1편에서는 눈 내리는 효과, 트리 곡선 그리기, 전구 흔들기 애니메이션이 있으며, 2편에서는 특정 물체에 눈이 쌓이는 애니메이션에 대해 이야기해 보겠습니다. 그럼 먼저 첫 번째로 눈 내리는 효과에 대해 살펴보겠습니다.
눈 내리는 효과
눈 내리는 효과를 구현하려면 눈이 어떻게 내리는지 알아야 합니다. 우선, 눈 내리는 장면을 한번 보겠습니다.
Figure 1-2 그림처럼 ‘눈송이’는 ‘비’처럼 일직선으로 내리지 않고, 한쪽 방향으로 움직이거나 좌우 반복적인 움직임을 가진다라는 것을 위 그림을 통해 알 수 있습니다.
이 반복적인 패턴의 움직임을 통해 마치 눈이 내리는듯한 움직임을 구현할 수 있습니다. 그러면 이 반복적인 움직임을 주기 위해 사용했던 개념이 진폭과 주기
입니다.
진폭과 주기
다음 그림은 진폭과 주기에 대한 파동을 표현한 것입니다.
이 그래프를 90°로 한번 돌려보겠습니다.
빨간색 선과
파란색 선의 움직임이 마치 눈이 내리는
듯한 움직임과 비슷하다는 것을 알 수 있습니다. 결국 이 파동의 움직임을 이용하여
눈송이의 움직임을 구현할 텐데요. 위 그래프에서
초록색 원의 각도가 증가함에 따라 -1 ~ 1
사이의 값을 계속해서 반복적으로
얻을 수 있습니다. 이 값을 눈송이의 x값에 넣어주면
x는 -1 ~ 1 사이의 값을 반복적으로 가질 수 있습니다.
constructor(x: number, y: number, radius: number, deviceWidth: number) {
this.x = x;
this.y = y;
this.radius = radius;
this.aVelocity = 0.01;
this.mass = this.radius / 1.6;
this.angle = Math.random() * 360 * (Math.PI / 180);
this.amplitude = Math.random() * (deviceWidth / 2);
}
animation() {
this.angle += this.aVelocity;
this.x = Math.sin(this.angle) * this.amplitude;
this.y += this.mass;
}
- this.angle(각도)에 this.aVelocity(0.01)를 계속 더해줍니다.
- this.x값은 각도가 증가함에 따라 -1 ~ 1 사이의 값을 반복적으로 가집니다.
- this.x에 특정한 값(this.amplitude)을 곱하게 되면 this.x값은 -this.amplitude ~ this.amplitude사이의 값을 반복적으로 얻을 수 있습니다.
- this.y값은 일정한 값을 더해주면 위에서 아래로 움직이기 때문에 this.x, this.y에 의해서 좌우로 왔다 갔다 하는 눈의 움직임을 구현할 수 있습니다.
결국 animation() 메서드를 모든 ‘눈송이’에 각각 적용함으로써, 각기 다른 진폭(this.amplitude)을 가진 눈송이의 움직임을 구현할 수 있습니다. 이처럼 눈 내리는 효과의 구현 방법은 아주 간단합니다. 이뿐만 아니라 파도, 눈, 바람 등 자연에서 관찰되는 요소들의 기본적인 움직임은 파동을 활용해서 구현할 수 있는 것입니다. 다음으로 트리 철사 모루(곡선 그리기) 방법을 얘기해 보겠습니다.
트리 철사 모루(곡선 그리기)
트리 디자인을 보면 각각의 철사 모루에 전구가 달려있습니다. 이 전구를 배치하기 위한 방법은 여러 가지가 있지만 좀 더 효율적인 방법으로 배치를 하기 위해서 Javascript Canvas를 이용해 곡선을 그린 후에, 그 곡선 위에 전구를 배치하게 되었습니다.
일반적으로 Javascript Canvas에서 곡선을 그리기 위한 API는 quadraticCurveTo 와 bezierCurveTo 2가지가 있습니다. 차이점은 bezierCurveTo는 점 4개를 이용해서 3차원 베지어곡선을 그리기 위한 API입니다. 그런데 저는 이 2가지 API를 트리 철사 모루를 그리기 위해서 사용하진 않았습니다.
그 이유는 뒤에 사용할 애니메이션(2편에 나올 내용)과 관련이 있는데요. 막연하게 곡선을 그리기 위한 용도라면 2가지 API만 사용해서 canvas에서 곡선을 표현할 수 있습니다. 하지만 곡선 위에 전구를 배치하기 위해서는 곡선의 좌표 하나하나를 알아야 합니다. 만약 곡선의 특정 위치에 전구를 배치하고 싶다고 가정해 봅시다. 그러면 이 2가지 API는 단순히 곡선을 그리기 위한 Point만 알고 있습니다. 곡선의 가운데 좌표나 곡선 중간중간의 좌표는 알 수가 없습니다.
그래서 여기서 선형 보간법
이라는 개념을 이용하였습니다.
선형 보간법
두 점 A, B 사이값 P를 추정하기 위해 선분 AB 에 따라 선형적으로 계산하는 방법입니다. 보다 쉽게 이해를 하기 위해서 Figure 3-3를 한번 보겠습니다.
A = 0, B = 1이라고 했을 경우 선분 AB 전체를 100% 보았을 때 t = 0.5(50%)라고 하면 임의의 점P는 퍼센트(percent)에 의해서 절반에 위치합니다. 여기서 t를 0.2라고 가정해 보겠습니다. 그러면 점P는 퍼센트에 의해 선분 AB의 20%에 해당하는 위치를 Figure 3-3처럼 찾을 수 있습니다. 이를 이용해서 t에 따라 선분 AB 사이의 임의의 점을 퍼센트(percent)로 찾을 수 있습니다. 이 개념을 바탕으로 곡선을 한번 그려보겠습니다.
Quadratic curve
곡선을 그리기 위해서는 기준이 되는 좌표 3개를 이용해야 하는데요.
선형 보간법을 이용해 두 선분 사이의 임의의 점을 퍼센트로 찾아보겠습니다.
- t = 0.5일 때는 각 선분 위에 임의의 좌표는 선분 가운데 노란색 좌표가 위치합니다.
- 각 선분 위에 있는 임의의 점은 t에 따라 퍼센트로 위치가 바뀌게 됩니다.
- 두 개의 노란색 좌표들도 그 사이 값을 같은 t에 의해서 선형 보간법을 적용합니다.
- 두 노란색 좌표 사이값의 이동 경로를 그리게 되면 Figure 3-4과 같이 quadratic bezier curve, 즉 곡선을 그릴 수가 있습니다.
프로그래밍에서 선형 보간법을 사용할 경우 (위키피디아 참조)
lerp(i: number, j: number, t: number) {
return (1 - t) * i + t * j;
}
3차원 베지어 곡선
트리에서 곡선을 더욱더 정밀하게 컨트롤하기 위해서 3차원 베지어 곡선을 사용하였습니다. 3차원 베지어 곡선을 그려주기 위해 lerp메서드를 활용해서 기준이 되는 4개의 좌표(x1, y1, x2, y2, x3, y3, x4, y4)를 인자로 받는 메서드(getBezierCurvePoint)를 다음과 같이 만들어 주었습니다.
bezierCurve(x1: number, y1: number, x2: number, y2: number, x3: number, y3: number, t: number) {
const cx = this.lerp(x1, x2, t);
const cy = this.lerp(y1, y2, t);
const px = this.lerp(x2, x3, t);
const py = this.lerp(y2, y3, t);
const x = this.lerp(cx, px, t);
const y = this.lerp(cy, py, t);
return { x, y };
}
getBezierCurvePoint(
x1: number,
y1: number,
x2: number,
y2: number,
x3: number,
y3: number,
x4: number,
y4: number,
t: number,
) {
const c1 = this.bezierCurve(x1, y1, x2, y2, x3, y3, t);
const c2 = this.bezierCurve(x2, y2, x3, y3, x4, y4, t);
const x = this.lerp(c1.x, c2.x, t);
const y = this.lerp(c1.y, c2.y, t);
return { x, y };
}
- bezierCurve메서드는 Figure 3-4 이미지를 보면 두 개의 선분을 각각 선형보간법을 이용해서 노란색 점 2개 (cx, cy) , (px, py)를 찾는 메서드입니다.
- 이 노란색 좌표 2개의 사이값을 다시 선형보간법을 이용해서 임의의 x, y을 찾습니다.
- getBezierCurvePoint메서드는 bezierCurve메서드를 이용하여 c1(xa, ya), c2(xb, yb) 두 개의 사이값을 찾습니다.
- 두 좌표(c1, c2)를 lerp를 이용하여 최종적인 곡선의 좌표(x, y)를 얻을 수 있습니다.
- 결국 총 4개의 좌표(x1, y1, x2, y2, x3, y3, x4, y4)를 이용해서 3차원 베지어 곡선을 그릴 수 있습니다.
getBezierCurvePoint메서드를 통해 4개의 좌표와 t에 의해서, Figure 3-2처럼 곡선 위에 전구를 퍼센트(percent)로 배치할 수 있습니다. 다음은 전구의 움직임에 대해서 얘기해 보겠습니다.
전구
트리에서 전구가 흔들리는 애니메이션이 있습니다.
이 전구의 움직임을 구현하기 위해서는 2가지 개념이 필요합니다.
첫 번째는 전구의 각 운동을 위한 뉴턴의 운동 제2법칙
과 두 번째는 전구의 위치를 위한 극좌표계
입니다.
먼저, 뉴턴의 운동 제2법칙을 이용한 각 운동에 대해서 얘기해 보겠습니다.
뉴턴의 운동 제2법칙
운동하고 있는 물체의 힘(F)은 그 물체의 질량(m)과 가속도(a)의 곱과 같다. 이것을 이용해서 전구의 움직임을 구현해 보겠습니다.
1. 정지해 있는 전구
정지해 있을 때 전구의 모습입니다. 위의 그림에서 질량(m)을 1로 두면, 결국 F = a와 같다는 결론을 얻을 수 있습니다. 저는 계산 방법을 쉽게 하기 위해서 질량을 1로 두고 계산하였습니다. F를 구할 수 있다면, F = a(가속도)이기 때문에, F에 의해서 위치를 변경시킬 수 있습니다.
물체 움직임의 기본 원리: 속도는 가속도에 의해 변하고, 속도는 물체의 위치를 바꾼다.
2. 초기 전구 각도
정지해 있는 전구마다 각각 초기 각도를 랜덤 하게 주었습니다.(여기서는 45°)
3. 전구의 힘
전구가 가질 수 있는 최대 각도에서 전구는 2가지 힘이 있습니다. 첫 번째는 지구가 물체를 잡아당기는 힘 중력가속도가 발생합니다. 그리고 두 번째는 전구의 가져야 할 힘입니다. 이 힘에 의해서 전구의 각도를 움직인다고 볼 수 있습니다. 그러면 전구가 가져야 할 힘(F)을 한번 구해보도록 하겠습니다.
4. 삼각함수를 이용한 힘 구하기
전구 각도와 줄을 Figure 4-5와 같이 평행사변형을 만들 수 있습니다. 평행사변형의 성질 중에 마주 보는 각은 같은 성질이 있습니다. 이것을 삼각함수를 이용해서 sin법칙을 이용하면 F(전구의 힘 방향)을 구할 수 있습니다.
sin45° = F / G or F = sin45° x G
라는 힘이자 가속도 값을 구할 수 있습니다.
constructor() {
this.angle = (angle * Math.PI) / 180
this.aVelocity = 0;
this.aAcceleration = 0;
this.radius = radius;
this.row = this.radius;
this.damping = 0.95;
}
animate() {
this.aAcceleration = Math.sin(this.angle) * ((-1 * gravity) / this.row); // 가속도
this.aVelocity += this.aAcceleration; // 속도
this.angle += this.aVelocity; // 전구 각도(위치)
this.aVelocity *= this.damping;
}
운동 제2법칙으로 구한 F(전구 힘 방향)을 this.aAcceleration(가속도)에 넣어줍니다. 여기서 this.row로 나누어준 이유는 this.row는 전구줄의 중심에서 전구까지의 줄 길이에 해당합니다. 그래서 전구 줄이 길면 각속도가 느리고 줄이 짧으면 각속도가 빠르기 때문에 줄 길이에 반비례하기에 this.row로 나누어 움직임의 디테일을 추가하였습니다.
this.angle(각도)에 this.aVelocity(속도)를 계속 더해줌으로써 각도는 계속 변할 수(움직일 수) 있게 되는 것입니다. 그렇다면 전구의 속도는 어떻게 자연스럽게 줄어드는 걸까요?
5. 전구의 속도
전구를 자연스럽게 멈추게 하기 위해서는 this.aVelocity(속도)에다가 1 이하의 값(this.damping)을 계속 곱해주면 되는데요. 밑의 표를 한번 보겠습니다.
감쇠 | 0.5 | 0.5 | 0.5 | 0.5 | 0.5 | 0.5 | 0.5 | 0.5 | 0.5 |
---|---|---|---|---|---|---|---|---|---|
속도(속도x감쇠) | 10(처음속도) | 5 | 2.5 | 1.25 | 0.625 | 0.3125 | 0.15625 | … | 0.00001 |
처음에는 속도가 10이었다가 damping에 의해서 속도는 0으로 수렴 (실제로 0이 되는 것은 아니다, 극한값이 0이다)을 하게 되는 것을 볼 수 있습니다. this.damping에 의해서 속도는 0에 한없이 가까워지고 있고, 이 극한값은 사람이 직관적으로 느끼기 어렵습니다. 0이 아니더라도 마치 정지한 것처럼 보일 수 있습니다.
Figure 4-6(극한값에서 정지한 것처럼 보이는 전구)
여기까지 전구의 각 운동에 대해 말씀드렸고, 이번에는 전구의 위치를 구하는 방법을 알아보도록 하겠습니다.
6. 직교좌표계와 극좌표계
전구의 위치를 쉽게 구하기 위해서는 극좌표계를 이용해야 하는데요. 보통 좌표를 나타내는 방법은 2가지가 있습니다.
직교좌표계: 직교좌표계는 데카르트 좌표계라고도 하는데 원점을 기준으로 x축, y축으로 구성된 좌표
극좌표계: 평면 위에 각도와 길이에 의해서 구성된 좌표
극좌표계를 이용한다면 전구의 각도와 전구 줄에 의해서 원하는 전구 위치를 구할 수가 있습니다.
극좌표계는 sin법칙과 cos법칙을 이용해서 구할 수 있습니다. 위 그래프를 이용하면
cos법칙 : cos() = x / r; => x = cos() * r;
sin법칙 : sin() = y / r; => y = sin() * r;
그러나 실제로 제가 적용했던 전구의 x, y 코드를 보면 x, y를 구하는 값이 sin, cos이 바뀐 걸 볼 수 있습니다.
const x = Math.sin(this.angle) * this.row;
const y = Math.cos(this.angle) * this.row;
x, y에 sin, cos을 바꾼 이유는?
보통 그래프에서 x축 방향에서 반시계방향으로 각도가 증가합니다. 그런데 전구의 각도가 0일 때(정지하고 있을 때)는 아래와 같은 모습입니다.
이를 x축 y축에 빗대어 보면 전구가 y축 방향을 기준으로 멈춰져 있습니다.
기준 축이 (y축)으로 바뀌었기 때문에 바뀐 축으로 다시 한번 삼각함수를 적용해 보면
const x = Math.sin(this.angle) * this.row;
const y = Math.cos(this.angle) * this.row;
이와 같은 식을 세울 수 있습니다. 결국 this.aAcceleration(가속도)에 의해서 각도는 계속 변화하게 될 테고, 그 변화하는 각도와 전구 줄 길이를 극좌표계를 이용한다면 좌우로 움직이다가 천천히 멈추는 전구를 만들 수 있습니다.
마치며
이번 포스팅을 통해 라이브러리 없이 다양한 애니메이션 개발이 가능하다는 점을 공유드렸습니다. 그럼 “왜 라이브러리를 사용하지 않고 어렵게 직접 만드세요?”라는 궁금증이 생길 거 같아요. 라이브러리를 사용했었더라면 이런 움직임을 구현할 때 필요한 개념과 방법이 있었는지도 몰랐을 겁니다. 이러한 애니메이션을 구현하기 위해 고민해 보고 생각해 보는 힘(사고력)을 기르기 위한 훈련을 하고 있다고 생각해 주시면 좋을 거 같습니다.
‘문제 해결 능력’은 high level engineer들에게는 굉장히 중요하다. 개발 서적 한 권을 다 본다고 생기는 것은 아니다. - 인터랙티브 김종민
앞으로 AI, 인공지능 시대가 오면서 프로그래밍이 필요 없는 세상(엔비디아 CEO, 젠슨 황)이 올 지도 모릅니다. 개발자가 이미 만들어 놓은 것만 사용하게 된다면 수로화(canalization: ‘사고의 경직’을 뜻하는 심리학 용어)의 한계에 갇히게 됩니다. 물이 수로를 따라 한 방향으로만 흐르듯, 한 방향으로만 생각하는 경향을 갖게 되는 것입니다. 직접 개발해 보고 만들어봐야지만 원리를 이해할 수 있습니다. 즉, 수로화를 깨트려야지만 더욱더 창의적으로 개발하며 라이브러리 역시 잘 활용할 수 있을 거라고 생각합니다.
2편에서는 특정 물체에 눈이 쌓이는 애니메이션에 대해 이야기할 예정입니다. 곧 업데이트할 예정이니 많은 관심 부탁드립니다.
참고 자료
- https://p5js.org/ko/examples/simulate-snowflakes.html
- Nature of code - 자연계 법칙을 디지털 세계로 옮기는데 필요한 개념
- 인터랙티브 김종민 - 선형보간법