얼쑤의 팀 캐릭터 절쑤는 갓을 쓰고 한복을 입은 백호입니다. 캐릭터가 확정된 뒤 앱 곳곳에 쓸 수 있도록 포즈 시트의 일러스트를 2초짜리 움짤로 만들었습니다. 감정 표현 12종과 정면·옆면·뒷면 같은 기본 포즈 7종입니다. 시트에서 포즈를 하나씩 잘라 낸 그림에 HTML과 GSAP로 점프, 흔들기, 숨쉬기 같은 움직임을 붙였습니다. 그 장면을 HTML을 영상으로 렌더링하는 오픈소스 도구 HyperFrames로 480×480, 15fps GIF로 뽑았습니다. 구현은 AI 코딩 에이전트에게 맡기고, 저는 결과 프레임을 보며 방향을 정했습니다.
문제는 원본 그림 한 장에 캐릭터와 발밑 그림자가 함께 그려져 있다는 점이었습니다.
그림자까지 같이 뛰었다
처음 만든 움짤은 그림 전체를 위로 올려 점프를 표현했습니다. 그러자 발밑 그림자도 캐릭터와 함께 떠올랐습니다. 그림자가 같이 뜨면 바닥까지 들리는 것처럼 보여서, 점프하는 포즈부터 그림자를 바닥에 남겨 두기로 했습니다.

예전 GIF는 남아 있지 않아서, 왼쪽은 같은 그림을 점프 높이만큼 통째로 올려 다시 만든 장면입니다. 오른쪽은 지금 GIF에서 같은 순간의 프레임입니다.
그림자 띠와 발을 나누기
그림자는 발밑에 가로로 길게 깔린 띠입니다. 띠가 시작되는 행부터 아래쪽은 그림자로 보고 바닥 레이어로 옮겼습니다. 다만 발 아랫부분도 그 띠 안에 걸쳐 있어서, 바닥에 닿은 발은 아래쪽 반 타원으로 지정해 몸 쪽에 남겼습니다. 꼬리 끝이 바닥에 닿은 포즈는 꼬리도 같은 방식으로 남겼습니다.

처음에는 경계를 자동으로 찾으려 했습니다. 외곽선에 둘러싸인 흰 영역을 발로 보는 방식이었습니다. 하지만 손으로 그린 외곽선의 작은 틈으로 털 부분이 바깥 배경과 이어졌고, 크림색 털은 흰색 기준에 걸리지도 않았습니다. 결국 포즈마다 발 양쪽 외곽선의 위치와 발바닥 높이를 픽셀 단위로 읽어 타원으로 지정했습니다.
떼어 낸 뒤 두 가지를 더 손봤습니다.
- 바닥 레이어에는 발에 가려 있던 자리가 비어 있습니다. 발이 떠오르면 그 구멍이 드러나므로, 줄마다 구멍 왼쪽과 오른쪽의 그림자 색을 선형 보간해 채웠습니다.
- 그림자 띠는 발 밑보다 바깥쪽 끝이 몇 행 더 위에서 시작합니다. 발 밑을 기준으로 자르자 바깥 끝의 얇은 회색 줄이 몸에 남아 같이 떠올랐습니다. 띠의 위쪽 경계를 바깥 끝에 맞춰 올려 해결했습니다.
점프하는 동안에는 바닥 그림자를 최고점에서 75% 크기, 절반 진하기까지 줄였다가 착지할 때 되돌립니다.
분리했더니 그림자가 사라졌다
원본 그림은 흰 배경이라, 두 레이어는 몸 이미지에 mix-blend-mode: multiply를 걸어 겹치기로 했습니다. 흰색은 아래가 그대로 비치고 선과 색만 남는 방식입니다. 그런데 렌더링한 GIF에서 바닥 그림자가 보이지 않았습니다. 몸 이미지의 흰 배경이 그림자를 덮고 있었습니다.
원인은 이미지를 감싼 몸 div였습니다. GSAP으로 회전과 이동을 주는 이 div에는 transform이 걸려 있고, transform은 새 쌓임 맥락(stacking context)을 만듭니다. mix-blend-mode는 자기가 속한 쌓임 맥락 안에서만 섞입니다. 그래서 이미지는 비어 있는 div 안에서만 multiply됐고, 형제인 그림자 레이어까지는 닿지 않았습니다. 블렌드를 이미지가 아니라 transform이 걸린 div 자체에 걸자 그림자가 드러났습니다.
// transform이 걸린 body는 격리된 그룹이라, 안쪽 img의 multiply는 바닥 레이어에 닿지 않는다.
if (split) body.style.mixBlendMode = "multiply";
그림자를 나누기 전에는 몸 아래에 다른 레이어가 없어서 드러나지 않던 문제였습니다.
흔들리는 포즈는 발을 축으로
점프 포즈를 고친 뒤, 좌우로 흔들리는 포즈도 그림자를 떼어 낼 수 있는지 살폈습니다. 기존에는 그림 전체를 아래 가운데를 축으로 회전시켰습니다. 그림자도 함께 기울 때는 괜찮았지만, 그림자를 바닥에 고정하면 기울어지는 쪽 발이 바닥 아래로 내려갑니다.

그래서 기울어지는 쪽 발을 축으로 삼았습니다. CSS에서 양수 회전은 시계 방향이라 오른쪽이 내려갑니다. 오른쪽으로 기울 때는 오른발 바닥을, 왼쪽으로 기울 때는 왼발 바닥을 transform-origin으로 씁니다. 몸이 두 발 위에서 번갈아 구르는 움직임이 되고, 발은 바닥 아래로 내려가지 않습니다.
축은 회전값이 정확히 0°인 순간에만 바꿉니다. 회전이 0이고 배율이 1이면 어떤 점을 기준으로 해도 결과가 같아서, 그때 기준점을 옮겨도 화면이 튀지 않습니다. 0°에서 최대 각도로 갔다가 0°로 돌아오는 반주기를 하나의 트윈 묶음으로 만들고, 그 시작에서 기준점을 정했습니다.
const q = period / 4;
for (let s = 0; s < D; s += period) {
[[pivotR, amp], [pivotL, -amp]].forEach(([origin, rot], i) => {
const t0 = s + i * 2 * q;
tl.set(body, { transformOrigin: origin }, t0);
tl.fromTo(body, { rotation: 0 }, { rotation: rot, duration: q, ease: "sine.out", immediateRender: false }, t0);
tl.to(body, { rotation: 0, duration: q, ease: "sine.in" }, t0 + q);
});
}
여기서 한 번 더 걸렸습니다. 정면 포즈는 흔들기와 함께 숨쉬기를 합니다. 숨쉬기는 세로로 조금 늘리면서 가로를 조금 줄이는 움직임인데, 1초 지점의 축 전환이 숨쉬기가 가장 큰 순간과 겹쳤습니다. 회전은 0이었지만 가로 배율이 0.992였고, 기준점을 옮기는 순간 그림이 옆으로 밀렸습니다. 두 발 축 사이 거리로 계산하면 약 0.7px입니다.
눈으로는 놓치기 쉬운 크기라 프레임 사이 차이를 숫자로 확인했습니다. 연속한 두 프레임의 평균 픽셀 차이(0255 회색조)를 재면, 나머지 구간은 1.95.0 사이에서 부드럽게 오르내리는데 그 지점만 7.4였습니다. 두 발 축은 높이가 같아서 세로 배율은 영향을 주지 않습니다. 흔들리는 포즈의 숨쉬기는 세로 배율만 쓰도록 바꿨고, 다시 재니 그 지점은 4.8로 이웃 프레임과 이어졌습니다.
여기 보세요 포즈는 원래 손짓하는 방향으로 10px 미끄러지며 기울었습니다. 그림자가 고정되면 발이 바닥 위를 끌려가는 것처럼 보여서, 미끄러짐을 빼고 오른발을 축으로 기울기만 하도록 바꿨습니다. 기울기는 3°에서 4°로 조금 키웠습니다.
확인한 것과 남은 것
19개 포즈 중 15개의 그림자를 분리했습니다. 점프하는 포즈 6개와 흔들리는 포즈 9개입니다. 포즈마다 분리 결과를 빨강과 파랑으로 칠한 이미지로 경계를 확인했고, 점프 최고점과 최대 기울기 프레임을 잘라 발과 그림자의 위치를 봤습니다. 흔들리는 포즈는 연속 프레임 차이를 계산해 축이 바뀌는 지점과 반복이 다시 시작되는 지점에 튀는 값이 없는지 확인했습니다.
분리하지 않은 포즈도 있습니다. 악보 공부는 앉은 자세라 바닥에 닿는 면이 넓고, 옆에 놓인 단소도 그림자와 함께 바닥 레이어로 옮겨야 합니다. 그런데 단소와 옷의 어두운 부분이 색으로 잘 구분되지 않았고, 흔들림도 1.5°로 작아서 그대로 두었습니다. 옆면과 잠깐 쉬어요는 숨쉬기만 해서 발이 바닥에서 떨어지지 않고, 몸을 숙이는 인사는 이번 작업에서 다루지 않았습니다.
발 경계는 포즈마다 직접 잰 값이라 그림이 바뀌면 다시 재야 합니다. GIF 한 장은 480px 기준 약 0.8~1.9MB여서, 앱에 넣을 때는 크기나 형식을 다시 정해야 할 수 있습니다.
그림 한 장을 움직일 때는 무엇이 캐릭터이고 무엇이 바닥인지부터 나눠야 했습니다. 바닥을 나누고 나니 회전 축과 블렌드처럼 레이어가 하나일 때는 보이지 않던 문제도 함께 드러났습니다.

댓글
댓글과 반응은 GitHub 계정으로 로그인해 남길 수 있어요.