본문으로 건너뛰기
갤러리로 돌아가기

모양을 저장하지 않는 소프트바디 타코피

이웃 간격과 목표 넓이 두 값으로 탄성을 만드는 타코피 데모를 정리했습니다.

p5.js물리 시뮬레이션

탄성체를 시뮬레이션으로 만든다면 가장 먼저 떠오르는 방법은 원래 모양을 저장해 두는 것입니다. 점마다 돌아갈 좌표를 기록해 두거나 이웃한 점들을 스프링으로 이어 원래 간격으로 당기게 하는 식입니다.

이 데모는 다른 방법으로 한번 접근해 봤어요. 필요한 값은 이웃 간격과 목표 넓이 두 개이고, 끌어서 눌렀다 놓으면 물체는 매번 같은 모양으로 돌아옵니다.

좌표도 스프링도 없이, 값 두 개에서 어떻게 탄성이 나올까?

방법은 물리 시뮬레이션을 공부하다 읽은 Constraints에서 가져왔습니다. Johnathon Selstad가 2019년에 쓴 인터랙티브 데모 모음으로, 흔들리는 줄부터 소프트바디까지 서로 다른 물체가 전부 한 가지 방식으로 움직여요. "이웃한 두 점은 정해진 간격을 넘지 않는다" 같은 제약을 먼저 정합니다. 조건이 깨지면 힘이나 스프링을 계산하지 않고, 조건을 다시 만족하는 가장 가까운 자리로 점을 곧장 옮깁니다. 이를 위치 투영(position projection)이라고 합니다.

이 방식을 직접 써 보고 싶어서 애니메이션 『타코피의 원죄』의 분홍 문어 외계인 타코피를 따라 그려 보기로 했습니다. 둥근 몸에 짧은 발이 달린 단순한 형태라 소프트바디로 옮기기 좋았어요.

저작권 안내

©タイザン5/集英社・「タコピーの原罪」製作委員会

타이잔5의 만화 『타코피의 원죄』(슈에이샤)와 그 애니메이션의 타코피를 따라 그린 비상업 팬아트 데모입니다. 공식 이미지를 가져다 쓰지 않고 전부 코드로 새로 그렸으며, 캐릭터 디자인의 저작권은 원작자와 위 권리자들에게 있습니다. 물리 시뮬레이션을 학습하는 목적으로만 만들었습니다.

먼저 살펴보기

타코피를 누른 채 끌면 누른 자리의 점들이 밀려나면서 몸이 찌그러지고, 표정은 우는 얼굴로 바뀝니다. 손을 놓으면 몸과 표정이 함께 제자리로 돌아와요. 마우스 대신 EnterSpace를 눌러도 위에서 한 번 눌렀다 떼는 것과 같은 반응이 일어납니다.

본문은 세 장면을 순서대로 따라갑니다.

  • 탄성 있는 몸통 — 스프링 없이 넓이를 지키려는 보정만으로 일어나는 복원
  • 몸 아래로 모이는 발 — 한 번 꺾이는 발 네 개가 자세를 잡는 방식과, 굴러간 몸을 바로 세우는 보정
  • 각이 없는 실루엣 — 계산하는 점은 16개뿐인데 윤곽은 매끈한 곡선이 되는 이유
인터랙티브 물리 애니메이션을 불러오고 있습니다.

점 16개로 만드는 몸통

몸통은 점 16개입니다. 원형으로 배치하고 이웃끼리 이어 닫힌 고리로 만들었습니다. 뼈대와 스프링이 하던 일은 아래에서 설명할 제약 두 개가 대신합니다.

각 점은 베를레(Verlet) 적분으로 움직입니다. 이 적분에는 속도 변수가 없어서 점의 상태는 이전 위치와 현재 위치 둘뿐입니다. 두 위치의 차이가 이번 프레임의 이동 거리가 되고, 거기에 감쇠를 곱해 조금 줄인 뒤 중력을 더하면 다음 위치가 나옵니다.

integrate(): void {
  const velocityX = (this.position.x - this.previous.x) * BODY_DAMPING; // 0.99
  const velocityY = (this.position.y - this.previous.y) * BODY_DAMPING + GRAVITY;

  this.previous = clone(this.position);
  moveBy(this.position, velocityX, velocityY);
}

여러 적분법 가운데 이것을 고른 것은 제약과 잘 맞기 때문입니다. 속도가 두 위치의 차이로 정의되므로 제약이 점을 다른 자리로 옮기면 다음 프레임의 속도도 그 자리를 기준으로 다시 계산됩니다. 속도를 따로 보정할 필요가 없다는 뜻입니다. 그래서 타코피를 움직이는 제약은 전부 "점을 조건에 맞는 자리로 옮긴다"라는 한 가지 일만 합니다.

이전 위치와 현재 위치의 차이로 관성을 얻고, 감쇠와 중력을 더해 다음 위치를 정하는 베를레 적분의 한 스텝
현재 위치와 이전 위치의 차이가 관성이 되고, 감쇠와 중력을 더한 자리가 다음 위치가 됩니다. 제약이 점을 옮기면 다음 프레임의 속도도 그만큼 바뀝니다.

적분만으로는 점 16개가 각자 흩어질 뿐이라 몸의 형태는 매 프레임 10번씩 다시 푸는 제약 두 개가 지킵니다. 첫 번째인 거리 제약은 이웃한 두 점이 정해진 간격보다 멀어지면 벌어진 만큼을 절반씩 나눠 서로 당겨 옵니다. 가까워지는 쪽은 막지 않아서 몸은 눌릴 수는 있어도 한없이 늘어나지는 않아요.

두 번째는 넓이 제약이고 눌렀다 놓았을 때의 복원이 여기서 나옵니다. 매 프레임 고리가 감싸고 있는 넓이를 신발끈 공식, 즉 꼭짓점 좌표만으로 다각형의 넓이를 구하는 공식으로 잽니다. 이렇게 잰 넓이가 목표보다 작으면 모자란 만큼을 모든 점에 나눠 각 점을 고리 바깥쪽으로 조금씩 밀어냅니다. 두 보정을 다시 푸는 10번의 반복은 몸의 강성을 정하는 값이기도 해서, 늘리면 더 단단해지고 줄이면 더 무르게 눌립니다.

벌어진 이웃을 절반씩 당기는 거리 제약과, 눌린 고리의 모든 점을 바깥으로 밀어내는 넓이 제약
이웃과 벌어진 점은 절반씩 서로 당겨지고(왼쪽), 넓이가 모자라면 모든 점이 바깥으로 밀립니다(오른쪽). 손을 놓았을 때의 복원이 오른쪽 보정입니다.

그런데 목표 넓이는 일부러 도달할 수 없는 값으로 잡았습니다. 처음 원 넓이의 1.5배입니다. 거리 제약은 둘레를 처음 길이 아래로 제한하고, 정해진 둘레로 감쌀 수 있는 가장 큰 넓이는 원의 넓이입니다(등주 부등식). 목표는 그 최대보다도 큽니다. 그래서 고리는 언제나 넓이가 모자란 상태로, 바깥으로 밀리는 보정을 계속 받으며 팽팽하게 부풀어 있습니다. 눌려서 넓이가 더 줄면 보정도 그만큼 커집니다.

이게 값 두 개에서 탄성이 나오는 방법입니다. 간격이 몸의 크기를 정하고 넓이가 눌린 곳을 다시 채우니, 두 값이 그대로인 한 도착하는 모양도 매번 같습니다. 타코피는 눌리기 전의 좌표로 돌아간다기보다 두 제약을 만족하는 같은 해로 매번 수렴하는 셈입니다.

남은 문제는 두 제약이 충돌하는 경우입니다. 거리와 넓이가 같은 점을 서로 다른 방향으로 밀 수도 있어서 보정을 그 자리에서 바로 적용하지 않고, 모아 뒀다가 평균을 내어 한 번에 반영합니다. Constraints의 소프트바디 데모가 쓰는 야코비(Jacobi) 방식 그대로입니다.

끌기 반응도 이 두 제약 위에 얹혀 있습니다. 입력 처리가 하는 일은 누른 지점에서 반경 100px 안에 들어온 점을 그 반경 밖으로 밀어내는 것뿐이고, 그다음의 찌그러짐과 복원은 두 제약의 결과입니다.

한 번 꺾이는 발

발은 네 개이며 각각 점 세 개로 이루어집니다. 점 세 개는 몸통 고리 아래쪽에 붙는 뿌리와 가운데에서 한 번 꺾이는 관절, 발끝입니다. 뿌리에서 관절까지와 관절에서 발끝까지 두 마디의 길이는 같습니다. 뿌리는 고리의 점 위가 아니라 점과 점 사이를 보간한 자리에 붙습니다. 덕분에 점 간격보다 촘촘하게 네 발을 바닥 가운데로 모을 수 있습니다. 몸이 흔들리면 발도 뿌리부터 함께 움직여요. 관절과 발끝은 몸통 점과 같은 베를레 적분으로 떨어집니다. 이어 매 프레임 관절은 뿌리에서, 발끝은 관절에서 정확히 한 마디 길이가 되는 자리로 되돌아옵니다.

거리 제약은 마디 길이까지만 정합니다. 한 마디 길이는 관절이 뿌리를 중심으로 한 원 위 어디에 놓여도 지켜지므로 발이 몸 위로 뒤집힌 자세까지 허용됩니다. 이를 막으려고 관절과 발끝이 놓일 수 있는 방향에 허용 각을 두고, 벗어난 점은 가장 가까운 경계 각도로 되돌립니다. 관절의 허용 각은 몸의 위아래 방향이 기준이고 발끝의 허용 각은 윗마디가 향한 방향이 기준이라, 몸이 기울면 발이 놓일 수 있는 방향도 함께 기웁니다. 네 발은 허용 각의 폭이 모두 같고 마디 길이와 기울기만 달라서 바깥쪽 두 발이 조금 더 길고 더 벌어진 채 넷이 몸 밑 가운데에 모여요.

몸통에 붙는 뿌리에서 관절, 발끝으로 이어지는 발의 세 점과 관절·발끝의 허용 각
뿌리가 움직이면 관절과 발끝이 마디 길이와 허용 각 안에서 자리를 다시 잡습니다.

허용 각이 몸을 따라가기 때문에 생기는 문제가 있습니다. 끌다 놓은 몸이 굴러간 채로 멈추면 허용 각 역시 그만큼 기울어, 발이 옆구리에 붙은 채로 섭니다. 몸통의 두 제약은 간격과 넓이만 지킬 뿐 회전은 되돌리지 않으므로 이 자세는 저절로 풀리지 않습니다. 그래서 회전을 되돌리는 보정을 추가했습니다. 매 프레임 정수리 양옆 두 점을 이은 선의 기울기를 재서 그 4%만큼 몸 전체를 중심 둘레로 되돌립니다. 현재 위치와 이전 위치를 같은 각도로 함께 돌리므로 새 속도가 생기지 않고, 몸은 튕기지 않고 천천히 바로 섭니다. 굴러간 타코피의 발이 다시 몸 아래로 모이는 장면이 이 보정의 결과입니다.

각이 없는 실루엣

매끈한 실루엣은 그리기 단계에서 나옵니다. 시뮬레이션의 출력은 점 16개의 좌표뿐이라 점을 직선으로 이으면 타코피는 16각형이 됩니다. 그래서 윤곽은 점을 지나는 스플라인 곡선으로 그립니다. splineVertex는 추가한 점을 전부 통과하는 곡선을 만들고, endShape(CLOSE)는 시작과 끝을 이음매 없이 잇습니다.

beginShape();
for (const point of blob.points) {
  splineVertex(point.position.x, point.position.y);
}
endShape(CLOSE);

발도 같은 스플라인으로 그리지만 몸통과는 다른 방식이 필요합니다. 몸통은 점 16개가 닫힌 윤곽을 만들어 안을 채우면 되는 반면, 발은 뿌리·관절·발끝 세 점을 지나는 곡선 하나라 채울 안쪽이 없기 때문입니다. 면 대신 선을 굵게 그어 두께를 만들고, 테두리는 같은 곡선을 두 번 긋는 것으로 해결했습니다. 굵은 먹색 선 위에 조금 가는 분홍 선을 겹치면 굵기 차이만큼 먹색이 가장자리에 남아 몸통과 같은 테두리가 돼요.

이렇게 선으로 그리면 선의 마감이 곧 발의 윤곽이 됩니다. 양 끝의 둥근 마감(strokeCap(ROUND))은 그대로 발끝의 볼록한 바닥이 되지만, 관절에서 꺾이는 자리는 기본 이음새(MITER)가 뾰족하게 튀어나와서 이음새도 둥근 것(strokeJoin(ROUND))으로 바꿨습니다. 남는 것은 뿌리 쪽 마감인데 이쪽은 모양을 고치는 대신 그리는 순서로 감췄습니다. 발을 몸통보다 먼저 그리면 뿌리 마감이 몸에 가려져서 몸 밑으로 나온 부분만 발로 보여요. 계산하는 점은 16개뿐인데 윤곽에 각이 없는 세 번째 장면은 이 스플라인과 둥근 마감의 결과입니다.

몸의 음영도 같은 요령으로 만듭니다. 발의 테두리가 두 선의 굵기 차이에서 나왔다면, 음영은 두 실루엣의 위치 차이에서 나옵니다. 실루엣 안쪽만 그려지도록 클리핑을 걸고 그늘색을 깐 뒤 본래 색의 실루엣을 왼쪽 위로 조금 밀어 겹치면, 가려지지 않은 아래와 오른쪽 가장자리에 그늘 띠가 남습니다. 왼쪽 위에서 빛이 오는 것처럼 보이는 이유입니다.

표정도 시뮬레이션이 아니라 그리기의 일입니다. 얼굴은 정수리를 기준으로 몸의 기울기를 따라 그려지고, 누르는 동안에는 점 두 개짜리 눈이 원작의 감은 눈과 눈물 자국으로 바뀝니다. 입은 두 표정에서 같아서 시뮬레이션에서 표정으로 넘어오는 정보는 눌림 여부뿐입니다.

마치며

타코피를 고른 것은 소프트바디로 옮기기 좋아 보이는 형태 때문이었습니다. 직접 옮겨 보니 그 짐작은 몸통에만 맞았어요. 둥근 몸은 정말 간격과 넓이 두 값으로 끝났습니다. 하지만 짧은 발에는 마디 길이와 허용 각뿐 아니라 몸을 바로 세우는 비율까지 필요했습니다. 화면을 보며 고른 값이 계속 늘어난 셈입니다. 완성된 화면에서 몸통은 저장해 둔 두 값이 지킵니다. 발과 자세는 골라 둔 값들이 지키고 있습니다.

참고 자료