API 없이 웹 애니메이션 구현: 인터랙티브 웹 개발 2편

API 없이 웹 애니메이션 구현: 인터랙티브 웹 개발 2편

요약: 2편에서는 트리와 별에 눈이 쌓이는 애니메이션을 다룹니다. 별에 눈이 쌓이는 애니메이션은 ‘공 튀기기’ 기법을 활용하여 ray casting을 통해 구현하며, 별과의 교차점을 찾아 눈을 멈추게 합니다. 트리의 경우, 곡선에 눈이 쌓이는 문제를 해결하기 위해 정사영 기법을 적용하여 눈이 곡선 위에 정확하게 쌓이게 합니다. 이 글은 다양한 애니메이션을 구현하는 방법론을 소개하며, 이를 통해 수학과 물리를 활용한 애니메이션 제작의 중요성을 강조합니다.

시작하며

안녕하세요, 카카오페이 채널 FE파트 디온입니다. 이번 2편에서는 특정 물체에 눈이 쌓이는 애니메이션을 다루겠습니다.

트리를 밝혀줘

트리 곡선에 눈이 쌓이는 애니메이션을 이해하기 위해서 API 없이 웹 애니메이션 구현: 인터랙티브 웹 개발 1편을 먼저 보는 것을 추천합니다. 지난 1편에서는 눈 내리는 애니메이션, 트리 철사 모루 그리기(곡선 그리기), 전구 움직이기 애니메이션을 다뤘습니다. 2편에서는 ray casting, 정사영을 이용해 트리 상단의 별과 철사 모루에 눈이 쌓이는 애니메이션을 얘기해 보겠습니다. 눈이 쌓이는 애니메이션 결과는 동일하지만, 별과 트리 철사 모루에는 다른 알고리즘을 적용했습니다. 그럼 별부터 딥다이브해 보겠습니다.

먼저, 현실 세계와 같이 눈이 트리 위에 떨어져서 쌓이게 하려면 어떤 식으로 구현해야 할까요? 제일 처음 떠오른 건 ‘공 튀기기’ 애니메이션인데요.

공 튀기기

위 영상은 공이 제일 바닥에 닿는 순간, 바닥의 반대 방향으로 공을 움직여 튀는 애니메이션을 구현한 모습입니다. 여기서 바닥 부분을 별의 한쪽면이라고 가정해 봅시다. 그러면 별의 모든 면을 체크해 그중 하나라도 닿는다면 눈의 속도를 0으로 만들어 주면 되지 않을까라고 생각했습니다.

그래서 그 바닥면을 체크하기 위해 적용했던 기법이 ray casting1입니다. Ray casting은 보통 게임 개발할 때 원근감을 주기 위해서 사용하는 기법인데요, 광선과 벽의 교차점을 찾아서 무엇을 보여줄지 말지를 결정하는 것입니다. 여기서 핵심은 교차점을 찾다입니다. 이 교차점을 이용해 눈이 쌓이게 할 것입니다. 그러기 위해서 2단계로 나누었습니다.

첫째, 눈이 내리다가 별과의 교차점을 찾습니다.
둘째, 눈의 속도를 0으로 만들어 그 자리에 눈을 멈추게 합니다.

이런 단계로 마치 별 위에 눈이 쌓이는 것처럼 효과를 낼 수 있습니다. 그럼 첫 번째로 ray casting을 이용해 눈과 별의 교차 과정을 자세히 살펴보겠습니다.

눈과 별 교차

Figure 1-2

눈이 진폭과 주기로 움직이다가 별의 한쪽 면과 교차했을 때의 모습입니다.

2차원 그래프 공간으로 이동

Figure 1-3

눈과 별의 교차 감지를 위한 식을 세우기 위해 2차원 좌표 공간에 표현해 보겠습니다. 그리고 눈과 선분 각각에 미지수를 설정하였습니다.

눈과 별의 선분 교차(y1 < y2)

Figure 1-4

눈이 움직이다가 별과 교차하면 Figure 1-4와 같은 상태가 됩니다. 여기서 눈의 yp에 한번 집중해 보겠습니다. 위 그래프에서 눈의 yp 좌표가 항상 빨간색 선분을 교차하려면 yp는 y2보다 작아야 하고, y1보다는 커야 한다는 것을 알 수 있습니다. 만약 yp가 y2보다 크거나 y1보다 작을 경우, Figure 1-4 영상에서 노란색과 빨간색 선분이 교차하지 않습니다. 노란색 선분 위에 yp가 있기 때문에, 노란색 선분이 교차하지 않으면 yp 역시 교차하지 않습니다. 그래서 항상 교차하려면 yp(눈의 y좌표)는 y1 < yp < y2입니다.

눈과 별의 선분 교차(y2 < y1)

Figure 1-5

선분을 미지수로 설정했기 때문에 y2가 y1보다 항상 크지는 않습니다. 그래서 그 반대의 경우(y2 < y1)도 교차가 되려면, yp는 y1보다 작아야 하고 y2보다는 커야 빨간색 선분을 교차할 수 있습니다. 이것의 비교식은 y2 < yp < y1입니다.

선분을 교차하는 눈의 y좌표

Figure 1-6

최종적으로 y1 < y2일 때의 선분과 y2 < y1일 때의 선분이 눈 yp의 좌표를 교차해야 합니다. 때문에 두 조건 중 하나라도 만족하는 식은 아래와 같습니다.

(y1 < yp && < yp < y2) || (y2 < yp && < yp < y1)

이 식을 조금 간단하게 표현하면 yp < y1 !== yp < y2입니다. 여기까지 y을 기준으로 yp의 교차영역을 알아보았습니다. 이제 눈의 x값을 구해볼까요?

기울기

Figure 1-7

그래프를 보면 빨간색 선분의 기울기를 알 수 있습니다.

기울기란? 선이 얼마나 가파른지를 나타내는 척도로 선 위의 두 점 사이의 수평 변화에 대한 수직 변화 비율을 말합니다.

기울기 = 수직 변화량 / 수평 변화량

선분의 기울기(or 가파른 정도)는 고정되어 있으며, y값이 증가하거나 감소하면 x값도 함께 변합니다.

기울기(100) = 100(y) / 1(x)
기울기(100) = 200(y) / 2(x)
기울기(100) = 300(y) / 3(x)

y 변화율에 의한 x

Figure 1-8

그래서 y의 변화율을 이용하여 x값을 구할 텐데요. y의 변화율에다가 x의 전체 범위를 곱하면 y가 변하는 비율에 의한 x값을 구할 수 있는데, 그 식이 아래와 같습니다.

rateX = ((yp - y1) / (y2 - y1)) * (x2 - x1);

위의 식을 보면

  • 선분 y의 전체 길이(y2 - y1)를 구합니다.
  • 선분 위에 있는 눈의 좌표 yp의 길이(yp - y1)를 구합니다.
  • y의 변화량은 (yp - y1) / (y2 - y1)입니다.
  • y의 변화량에 x의 전체 크기(x2 - x1)를 곱하면, y의 변화에 따른 x값을 구할 수 있습니다.

눈의 x좌표

이렇게 구한 rateX값이 y의 비율에 의한 x의 크기입니다. 여기서 x1을 미지수로 설정했기 때문에 어떤 값인지 모릅니다. x1은 0일 수도 있고, 아닐 수도 있습니다. 그렇기 때문에 rateX 값을 바로 사용할 수가 없습니다. 아래 그림을 보면 이해할 수 있을 것입니다.

Figure 1-9

위 그래프에 초록색바(rateX)의 크기가 2입니다. 눈의 교차 x좌표가 2라는 의미가 아니라, 크기가 2이기 때문에 x1의 위치에 따라 x1 값을 더해줘야 최종적으로 2차원 그래프에서 눈의 교차 x좌표를 찾을 수 있습니다.
결국 x1 + rateX가 선분을 교차하는 눈의 x좌표인 것이고, 교차하는 눈의 x좌표가 현재 눈의 xp보다 커야 교차한다고 볼 수 있습니다.

눈과 별의 한쪽 선분을 교차하는 최종 식은 아래와 같습니다.

   const rateX = ((yp - y1) / (y2 - y1)) x (x2 - x1); // y 변화율에 따른 x좌표
   const x = xp < x1 + rateX
   const y =  yp < y1 !== yp < y2;

   if(x && y) {
      return '교차'
   }

이제 별의 모든 선분의 교차점을 찾기 위해 for 문을 이용해 아래와 같은 식을 만들어 볼 수 있었습니다.


raycasting(target: VectorType, point: VectorType[] = []) {
   let isIntersection = false;

   for (let i = 0, j = point.length - 1; i < point.length; j = i++) {
      const p1 = point[i];
      const p2 = point[j];

      const x = target.x < p1.x + ((target.y - p1.y) / (p2.y - p1.y)) * (p2.x - p1.x);
      const y = target.y < p1.y !== target.y < p2.y;

      if (x && y) {
         isIntersection = !isIntersection;
      }
   }

   return isIntersection;
}
Figure 1-10

별의 선분을 이렇게 p1, p2로 두고 ray casting으로 교차점을 감지합니다. for문을 이용해 선분과 눈의 교차점을 10번 찾습니다. 이렇게 별의 모든 선분의 교차점을 찾아 눈이 특정 영역에 쌓일 수 있는 애니메이션을 구현하게 되었습니다.

시작하며에서 ‘별과 트리 곡선에 각각 다른 알고리즘으로 눈 쌓이는 애니메이션을 적용했다고 언급했습니다. 트리 철사 모루에 ray casting 기법의 문제가 있었는데요, 무엇이 문제였는지 알아보겠습니다.

트리 철사 모루

Figure 1-11 (트리 곡선에 눈 쌓이기 비교화면)

위 영상은 트리 곡선에 ray casting을 적용한 것과 다른 기법을 적용한 비교 화면입니다. 둘 다 트리에 눈이 쌓이기는 하지만 왼쪽 트리를 자세히 보면 곡선의 특정 영역에 눈이 정확하게 쌓이지 않는다는 것을 볼 수 있습니다. 바로 이 부분이 문제였습니다. 그렇다면 이 문제가 왜 발생했을까요?

Ray casting을 이용해 교차점을 찾는 알고리즘을 다시 한번 보겠습니다.


raycasting(target: VectorType, point: VectorType[] = []) {
   let isIntersection = false;

   for (let i = 0, j = point.length - 1; i < point.length; j = i++) {
      const p1 = point[i];
      const p2 = point[j];

      const x = target.x < p1.x + ((target.y - p1.y) / (p2.y - p1.y)) * (p2.x - p1.x);
      const y = target.y < p1.y !== target.y < p2.y;

      if (x && y) {
         isIntersection = !isIntersection;
      }
   }

   return isIntersection;
}

이 알고리즘은 target이 모든 선분을 처음부터 끝까지 비교하기 때문에, 반드시 처음 좌표와 마지막 좌표의 선분을 지나는 교차점을 찾게 되어 문제가 발생합니다. 아래 Figure 1-12을 보면서 자세히 얘기해 보겠습니다.

Figure 1-12 (ray casting을 이용한 곡선의 교차 감지)

선형 보간법을 이용하여 곡선을 그렸기 때문에 구하고자 하는 위치를 퍼센트로 빨간색 좌표를 Figure 1-12 같이 8개 구합니다.(1편 내용 참조) 기준이 되는 10개의 좌표를 각각 p1, p2로 하나의 선분으로 두고 각 선분마다 교차 지점을 감지합니다. 계속 비교하다가 처음과 마지막 좌표가 p1, p2일 때 p1, p2의 선분이 생기고, 이 선분의 교차 지점을 찾게 됩니다. 이것이 Figure 1-11처럼 처음과 끝을 지나는 선분에 교차점이 생겨 눈이 곡선 위에 쌓이지 않고 직선에 쌓이는 것이었습니다.

이것을 해결하기 위해 처음에는 ‘처음과 끝을 비교하지 말자’라고 생각했습니다.

Figure 1-13

하지만 Figure 1-13처럼 그다음 첫 번째와 그다음 마지막번째의 두 좌표를 지나는 선분에 눈이 쌓이게 되면서 이 알고리즘은 닫힌 다각형에만 적용된다는 것을 알게 되었습니다. 그래서 곡선에 눈 애니메이션을 적용하기 위한 새로운 개념이 수학의 정사영(Orthogonal Projections)입니다.

정사영이란?

Figure 1-14

정사영이란? 두 벡터가 있을 때 b벡터를 a벡터 위로 수선의 발을 내린 것을 벡터의 정사영이라고 합니다.
다음 Figure 1-15을 보겠습니다.

Figure 1-15 (벡터의 정사영)

Figure 1-15는 곡선을 선형 보간법으로 기준이 되는 10개의 좌표를 구합니다. 그런 후 각 좌표의 벡터를 계산하여 눈 벡터를 정사영(빨간색 좌표)합니다. 여기서 눈이 움직이다가 눈의 좌표(xp, yp)와 정사영 좌표가 같아졌을 때 눈의 속도를 0으로 만들어 줍니다. 그러면 그 순간 곡선 위에 눈이 멈춤으로써 마치 곡선 위에 눈이 쌓이는 것처럼 효과를 낼 수 있습니다.
이것을 코드와 함께 그 과정을 다시 한번 자세히 살펴보겠습니다.

Figure 1-16

선형 보간법을 이용해 곡선의 10%, 20%, 30%… 100% 해당하는 위치를 찾습니다. 각 좌표를 p1, p2로 두고 눈과 각 좌표의 벡터를 구합니다. 그래서 Figure 1-16처럼 vec1, vec2를 구합니다. 정사영의 좌표는 삼각함수(코사인 법칙)를 이용해서 구할 수 있습니다.

하지만 삼각함수를 이용하려면 두 벡터 사이의 각도를 알아야 합니다. 각도를 구하는 방법은 내적을 이용하거나 atan, atan2를 이용해 구할 수 있습니다. 문제는 눈이 진폭과 주기로 계속 움직이며, vec1벡터와 vec2벡터 사이 각도를 지속적으로 계산해야 한다는 점입니다. 결국 시간 복잡도가 높아집니다.

보다 효율적인 계산을 위해, 각도를 모를 때 정사영을 구해보겠습니다. 벡터의 정사영은 기본적으로 크기와 방향을 가지고 있습니다. 그래서 이 정사영 벡터의 크기방향을 따로 구한 후 그것을 합쳐주면 정사영 좌표를 구할 수 있습니다.

정사영 크기와 방향

정사영의 그림을 다시 한번 보겠습니다.

Figure 1-17

Figure 1-17를 보면 cos법칙을 이용해서 정사영의 크기를 구할 수 있습니다.
cosθ = |proj| / |b|
(정사영 시킨 스칼라를 proj라고 해보겠습니다)

|proj|를 좌항에 두면 |proj| = |b| * cosθ 식이 됩니다. 여기서 각도를 모르기 때문에 내적을 이용해서 cosθ을 바꿔줍니다.

내적은 a · b = |a||b|cosθ

cosθ을 좌항으로 보내고 나머지를 우항으로 보내면 cosθ = a · b / |a||b| 됩니다. 이것을 |proj| = |b| cosθ에 대입하면,
|proj| = |b| x (a · b / |a||b|)되고, 식을 정리하면 정사영의 크기는 다음과 같습니다.

크기(proj) : a · b / |a|

이제 정사영의 방향을 찾아보겠습니다. 방향은 쉽습니다. Figure 1-17 이미지를 보면 정사영(proj)의 방향은 a방향과 똑같습니다. a벡터(크기, 방향)에 a의 스칼라(크기)로 나누어 주면 a의 방향을 얻을 수 있습니다.

방향(proj) : a / |a|

정사영 벡터(크기 x 방향) : a · b / |a| x a / |a| => (a · b / (|a||a|)) * a
(이 수식은 Figure 1-16의 result변수에 사용한 수식입니다.)

이렇게 정사영을 이용해 곡선 위에 눈이 쌓이는 애니메이션을 구현할 수 있었습니다. 하지만 글을 읽으면서 한 가지 약간 의문이 드는 부분이 있습니다. Figure 1-15을 보면, 정사영을 구하는 부분에서 정사영의 좌표가 정확하게 곡선 위에 있지는 않습니다. 그래서 애니메이션 구현할 때 약간의 오차가 있었는데요. 마지막으로 이것을 해결해 보겠습니다.

정사영의 오차

Figure 1-18

Figure 1-18을 보면 정사영의 좌표가 정확하게 곡선 위에 있는 것이 아니라, 직선의 선분 위에 있습니다. 그래서 선분과 곡선 사이의 틈만큼 오차가 발생합니다. 이 오차를 줄이기 위해서는 곡선을 좀 더 잘게 잘게 나누어(0.1%, 0.2%, 1%… 2%… 99.8%, 99.9%, 100%) 주면 되는데요. 곡선을 잘게 잘게 나누다 보면 곡선도 어느 구간에서 직선이 되는 부분이 있습니다. 그 구간을 찾아서 그때 벡터의 정사영을 구하면 정확하게 곡선 위에 눈을 쌓이게 할 수 있습니다.

하지만 곡선을 잘게 나눌수록 비교해야 할 정사영의 좌표가 많아지기 때문에 퍼포먼스 문제가 발생할 수 있습니다. 그래서 제가 10등분 만으로도 오차 범위를 줄였는데 어떤 방법으로 줄였는지 설명해 보겠습니다. 방법은 간단합니다. 바로 눈과 정사영 좌표가 일치했을 경우 정사영 좌표를 기준으로 상하좌우 랜덤하게 새로운 눈 여러 개를 생성했습니다.

이로 인해 2가지 효과를 볼 수 있었는데요.

  1. Figure 1-18처럼 직선과 곡선 사이의 틈을 새로운 눈이 그 공간을 채워 주었습니다. 그래서 마치 곡선 위에 눈이 정확하게 쌓이는 것처럼 보였습니다.
  2. 기존 눈과 새로운 눈이 여러 개 생성되면서 눈이 소복하게 쌓이는 효과를 볼 수 있었습니다.

이렇게 성능과 애니메이션의 오차 범위까지 고려하여 정사영을 이용해 곡선에 눈이 내리는 방법에 대해 알아보았습니다.
지금까지 1편에서는 진폭과 주기, 선형보간법, 뉴턴의 운동 제2법칙과 2편에서는 ray casting, 정사영을 이용해 애니메이션 개발 과정에 대한 내용을 말씀드렸습니다. 이렇게 라이브러리 사용 없이 수학과 물리를 이용한 애니메이션 개발을 통해 수로화를 깨트려봤습니다. 여기에 사용했던 수학과 물리의 개념이 꼭 정답은 아닙니다. 수학 문제에도 여러 가지 풀이 방법이 있듯이 기획, 디자인, 서비스 환경에 맞게 다양한 풀이 방법으로 애니메이션을 구현하면 될 거 같습니다.

마치며

제가 이런 애니메이션을 개발하다 보면 FE개발자, 디자이너, 앱 개발자 등 주변 크루에게 아래와 같은 질문을 많이 받습니다. ‘애니메이션 공부는 어떻게 하셨나요?’, ‘이런 애니메이션을 구현하기 위해서 이런 개념은 어떻게 아나요?’

저 역시도 이런 질문에 어떻게 답할지는 여전히 물음표입니다. 해답을 구하기 위해 인터랙티브 디벨로퍼 김종민님의 영상과 각종 유튜브, 책을 찾아봤지만 딱히 정답은 알 수 없습니다. 그렇다면 저는 현재 어떻게 이런 애니메이션을 만들 수 있게 되었냐면 그냥 꾸준하게 쉬운 거부터 따라 만들어 보았던 거밖에 없었습니다. 직접 만들어 보면서 새로운 지식을 알게 되었고, 이렇게 배운 기술에 익숙해지면서 다른 애니메이션에도 적용해 보고 자연스레 움직임을 주는 방법을 알게 된 것 같습니다.

‘기술에 익숙해지는 순간 비로소 더 자연스럽고 창의적인 것이 나온다. 그때부터 바로 애니메이션의 진정한 연기가 시작되는 것이다.’ - 리처드 윌리엄스

앞으로 더 창의적이고 자연스러운 연기(애니메이션)로 다시 돌아오겠습니다.

참고자료

공 튀기기 영상

Footnotes

  1. Ray casting(레이 캐스팅)은 3D 이미지를 2D 화면에 표현하는 컴퓨터 그래픽스에서 사용하는 계산 방법입니다. 관찰자의 눈에서 화면의 각 픽셀을 통해 발사된 광선이 시뮬레이션된 환경에서 어떤 객체와 교차하는지를 결정함으로써 작동합니다. 레이캐스팅은 광선이 교차하는 객체와의 상호작용만 계산함으로써 렌더링을 단순화하고, 계산이 덜 복잡해 비교적 빠른 속도로 3D 이미지를 렌더링 할 수 있게 합니다. 초기 3D 비디오 게임 개발에 특히 유용하게 사용되었습니다. 자세한 설명은 위키피디아를 참조해 주세요.

dion.ho
dion.ho

웹 애니메이션 개발을 좋아하는 FE개발자 디온입니다.

태그