본문으로 바로가기

12장 Animation - 뼈대 애니메이션(skeletal animation)의 이해

3D 게임의 애니메이션은 2D 게임에서의 애니메이션과 매우 다르다. 2D 게임에서의 프레임마다 이미지를 번갈아 보여주면서 움직이는 효과를 만들어낼 수 있지만, 3D 게임에서는 수학적 접근이 필요하다. 여기에서는 3D 게임에서 일반적으로 많이 사용하는 기법인 뼈대 애니메이션을 살펴본다.


뼈대 애니메이션의 기초

2D 애니메이션의 경우 게임은 연속되는 이미지 파일을 사용해서 움직이는 캐릭터의 환영을 만들어낸다. 하지만 3D 모델의 경우 수많은 삼각형으로 구성되어 있다. 일반적으로 3D모델 하나에 100KB가 넘는 경우도 흔하다. 이런 3D 모델을 2D 이미지 파일처럼 애니메이션을 구성한다고 생각해보자. 30 프레임 2초짜리 애니메이션에 필요한 모델은 총 60개이고, 이는 6MB 메모리가 요구된다. 게임에는 다양한 모델과 각 모델마다 다양한 애니메이션이 존재하기 때문에 이러한 접근방식은 과도한 메모리 사용량이 요구된다.

또한 다른 캐릭터라도 달리기와 같은 애니메이션 움직임은 거의 동일하다. 하지만 프레임 마다 모델을 새로 만든다면 거의 비슷한 애니메이션을 반복적으로 생산해야 하고 로드해야 하기 때문에 효율이 매우 떨어지게 된다. 이를 보완하기 위해 나온 것이 뼈대 애니메이션(Skeletal animation 혹은 Skinned animation)이다. 뼈대 애니메이션의 가장 큰 이점은 같은 뼈대를 가진다면 여러 다른 캐릭터에서도 잘 동작한다.

뼈대와 포즈

뼈대란?

뼈대뼈(bone)들의 집합이다. 뼈대 애니메이션에서 캐릭터는 단단한 뼈대를 가진다. 애니메이터는 이 뼈대를 애니메이션에 사용한다. 그리고 모델의 각 버텍스는 뼈대에 있는 하나 이상의 본과 연관된다. 애니메이션이 본을 움직이면 버텍스는 연관된 본에 따라 값이 바뀐다. 인체에서 뼈가 움직이면 피부가 움직이는 것과 같은 원리이다.

기존 메시 데이터 파일에서는 버텍스의 위치, 법선벡터, 텍스처 좌표를 데이터로 받았는데, 뼈대 애니메이션을 사용하기 위해서 여기에 추가적으로 이 버텍스에 영향을 주는 본의 인덱스(이 프로젝트에서는 최대 4개)와 각 인덱스의 가중치를 받는다. 예시로 프로젝트에서 사용될 메시 데이터파일를 참고하자.

뼈대를 일반적으로 표현하는 방법은 본의 계층 구조다. 루트 본은 계층 구조의 베이스이며 부모 본이 없다. 그 외 모든 본은 1개의 부모 본을 가진다. 이 본 계층 구조에서 사람이 자신의 어깨를 회전하면 팔은 그 회전을 따른다.

▲ Skeletal 예시(출처: https://arxiv.org/pdf/2005.00559.pdf)

포즈란?

포즈는 임의의 애니메이션 프레임에서 뼈대의 설정(본 각각의 위치와 회전 값)을 의미한다. 본의 포즈는 로컬 포즈와 전역 포즈가 있다. 로컬 포즈는 부모 본에 상대적이며, 전역 포즈는 모델의 오브젝트 공간 원점에 상대적인 좌표이다.(모든 버텍스 좌표는 오브젝트 공간 원점에 상대적인 좌표임을 기억하자) 루트 본은 부모가 없으므로 로컬 포즈와 전역 포즈가 동일하다. 기본적으로 모든 데이터는 로컬 포즈 데이터로 저장되어 있고 우리는 로컬 포즈 변환 행렬을 사용해서 부모 본의 좌표계로 변환한다.

그리고 애니메이션은 시간 경과에 따른 일련의 포즈 모음이다. 이번 프로젝트에서 사용될 JSON 타입의 애니메이션 데이터파일을 참고하자. 이 파일을 보면 각 프레임마다 본의 위치와 회전 값을 지정한 부분을 확인할 수 있다.

바인딩 포즈는 애니메이션을 적용하기 전 뼈대의 기본 포즈다. 바인딩 포즈에 대한 또 다른 용어로 t-포즈를 사용한다. 바인딩 포즈를 T처럼 설정할 경우 본과 버텍스를 연관시키기 쉽다. 그리고 일반적으로 아티스트는 캐릭터 모델을 만든 뒤 이 바인드 포즈 설정처럼 보이도록 초기화한다.

▲ 바인드 포즈 (출처:  https://www.okino.com/conv/skinning.htm)

전역 포즈 행렬 계산

각 본이 로컬 포즈 행렬을 가지가지고 있고, 각 본은 부모/자식 관계의 계층 구조로 이루어져 있기 때문에 각 본의 전역 포즈 행렬을 계산하는 것이 가능하다. 예를 들어 척추(Spine)의 전역 좌표는 다음과 같이 구할 수 있다.

\[[SpineGlobal] = [SpineLocal][RootGlobal]\]

부모본의 전역 포즈 행렬을 자신의 로컬 포즈 행렬에 곱하면 자신의 전역 로컬 포즈를 계산할 수 있다.

\[[HipLGlobal] = [HipLLocal][SpineGlobal]\]

로컬 포즈는 항상 전역 포즈로 변환 가능하므로 로컬 포즈만 저장하는 것이 합리적으로 보일 수 있지만, 전역 형태로 일부 정보를 저장하면 프레임마다 요구되는 계산 수를 줄일 수 있다.

본(bone)의 저장

이 본들의 변환(Transform)을 저장하는 한 가지 방법은 배열을 사용하는 것이다. 배열의 인덱스 0을 루트 본으로 시작으로 연속되는 본들의 정보를 저장한다. 본을 저장하기 위한 Bone 구조체를 다음과 같이 선언할 수 있다.

    struct Bone
    {
        BoneTransform mLocalBindPose;
        std::string mName;
        int mParent;
    };

본 구조체에서 사용된 본 변환 클래스는 다음과 같이 선언할 수 있다.

class BoneTransform
{
public:
    Quaternion mRotation;   // 본의 회전값은 뒤에 나올 회전 보간을 위해 쿼터니언을 활용
    Vector3 mTranslation;   // 본의 위치값 벡터

    Matrix4 ToMatrix() const;   // 본 변환을 행렬로 표현하기 위한 함수
};

그리고 이번 프로젝트에서 사용될 실제 뼈대 데이터파일을 참고하자. 이 뼈대 데이터 파일은 Skeleton class의 Load함수에서 읽어 들여 Bone배열에 저장하게된다. 이 부분의 구현부는 여기에서 확인하자.

인버스 바인드 포즈 행렬

본의 로컬 바인드 포즈 행렬에 부모의 전역 바인드 포즈 행렬을 곱하면 본에 대한 전역 바인드 포즈 행렬을 구할 수 있었다. 인버스 바인드 포즈 행렬은 전역 바인드 포즈 행렬의 역행렬이다. 만약 오브젝트 공간에 한 점이 주어졌을 때 해당 점에 인버스 바인드 포즈 행렬을 곱하면 해당점은 본의 로컬 바인드 포즈 행렬을 구할 수 있을 것이다. 인버스 바인드 포즈 행렬은 매우 유용하다. 왜냐하면 모델의 버텍스는 오브젝트 공간에 존재하기 때문에 버텍스에 인버스 바인드 포즈 행렬을 곱하여 오브젝트 좌표 공간의 버텍스를 본의 좌표 공간으로 변환시킬 수 있기 때문이다.

\[[SpineInvBind] = [SpineBind]^{-1} = ([SpineLocalBind][RootBind])^{-1}\]

위의 수식은 다음과 같이 구현할 수 있을 것이다. 각 본에 대한 인버스 바인드 포즈 행렬은 변하지 않으므로 뼈대를 로드할 때 이 행렬을 미리 계산해두면 좋다.

void Skeleton::ComputeGlobalInvBindPose()
{
    // 본의 수를 재조정한다. 본은 자동적으로 초기화된다.
    mGlobalInvBindPoses.resize(GetNumBones());

    // 단계 1: 각 본의 전역 바인드 포즈를 계산한다
    // 루트는 전역 바인드 포즈와 로컬 바인드 포즈가 동일하다.
    mGlobalInvBindPoses[0] = mBones[0].mLocalBindPose.ToMatrix();

    // 나머지 본의 전역 바인드 포즈는 자신의 로컬 포즈와 부모의 전역 바인드 포즈와의 곱이다.
    for (size_t i = 1; i < mGlobalInvBindPoses.size(); ++i)
    {
        Matrix4 localMat = mBones[i].mLocalBindPose.ToMatrix();
        mGlobalInvBindPoses[i] = localMat * mGlobalInvBindPoses[mBones[i].mParent];
    }

    // 단계 2: 각 행렬의 역행렬을 구한다.
    for (size_t i = 0; i < mGlobalInvBindPoses.size(); i++)
    {
        mGlobalInvBindPoses[i].Invert();
    }
}

애니메이션 데이터

뼈대의 포즈는 각 본의 로컬 포즈 모음이다. 그리고 애니메이션은 시간이 지남에 따라 플레이되는 일련의 포즈다. 개발자는 바인드 포즈를 사용해서 각 본의 로컬 포즈를 전역 포즈 행렬로 변환한다. 개발자는 이 애니메이션 데이터를 본 변환으로 구성된 동적 배열에 프레임 단위로 저장한다.

프레임 단위로 애니메이션을 저장할경우 게임 프레임 레이트와 애니메이션 프레임 레이트가 동일하지 않는 문제가 발생한다. 예를 들어 게임 프레임은 60 FPS인데 애니메이션의 프레임 레이트는 30 FPS 일 수 있다. 때문에 델타 시간으로 애니메이션을 갱신할 수 있어야 하고, 이를 지원하기 위해서는 애니메이션의 두 프레임 사이에 보간 된 값을 출력할 수 있어야 한다. 보간 함수는 다음과 같이 선언할 수 있다.

BoneTransform BoneTransform::Interpolate(const BoneTransform& a, const BoneTransform& b, float f)
{
    BoneTransform retVal;
    retVal.mRotation = Quaternion::Slerp(a.mRotation, b.mRotation, f);
    retVal.mTranslation = Vector3::Lerp(a.mTranslation, b.mTranslation, f);
    return retVal;
}

스키닝

스키닝은 3D 모델의 버텍스를 뼈대에 있는 하나 이상의 본과 연관시키는 작업을 의미한다. 모델의 스키닝 정보는 게임 중에 변하지 않으며, 이 정보는 버텍스의 속성으로 저장된다. 스키닝의 일반적인 구현에서 각 버텍스는 4개의 서로 다른 본과 연관된다. 4개의 본 각각이 버텍스에 얼마나 영향을 미치는지를 가중치를 지정하여 버텍스의 최종 위치를 계산할 수 있다. 그리고 모든 가중치의 합은 1.0 이어야 한다. 아래의 이미지는 하나의 버텍스에서 두 개의 본에 각각 (1/3, 2/3), (2/3, 1/3) 씩 연관을 가진 모습이다.

▲ 스키닝 예시

스키닝 계산

우선 계산의 편의를 위해서 하나의 본만을 가진 버텍스를 살펴보자. 그리고 이 버텍스는 척추 본에 유일하게 영향을 받는다고 가정하자. 모델은 바인드 포즈로 구성되며 버텍스 버퍼에 저장된 척추본에 영향을 받는 버텍스\(v\)는 오브젝트 공간에 존재한다. 하지만 임의의 포즈 \(P\)에서 모델을 그리려 한다면 바인드 포즈 기준 오브젝트 공간에 있는 버텍스를 현재 포즈인 \(P\)의 오브젝트 공간으로 변환해야 한다.

먼저 바인드 포즈상 오브젝트 공간에 있는 \(v\)를 바인드 포즈상 척추의 로컬 공간으로 변환하자.

\[v_{(InLocalBindPose)} = v[SpineInvBind]\]

\([SpineInvBind]\) 행렬은 앞서서 아래와 같이 구했음을 기억하자.

\[[SpineInvBind] = [SpineBind]^{-1} = ([SpineLocalBind][RootBind])^{-1}\]

다음으로 로컬 바인드 포즈상에 있는 버텍스 \(v\)에 현재 척추의 전역 포즈(오브젝트 공간)를 곱하면 버텍스는 오브젝트 공간으로 변환된다.

\[v_{(InGlobalCurrentPose)} = v_{(InLocalBindPose)}([SpineCurrentPose])\]

\[v_{(InGlobalCurrentPose)} = v([SpineInvBind][SpineCurrentPose])\]

이제 v가 2개의 본에 영향을 받는다고 가정하자. 척추로부터 0.75, 왼쪽 엉덩이로부터 0.25의 가중치로 영향을 받는다고 하면 다음과 같이 계산할 수 있다. (4개의 본에 영향을 받는 버텍스에 대해서도 확장하여 계산 가능하다.)

\[v_0 = v([SpineInvBind][SpineCurrentPose])\]

\[v_1 = v([HipLInvBind][HipLCurrentPose])\]

\[v_{(InGlobalCurrentPose)} = 0.75 \cdot v_0 + 0.25 \cdot v_1\]

행렬 팔레트

척추와 같은 일부 본은 캐릭터 모델의 수백 개의 버텍스에 영향을 미친다. 이 각각의 버텍스를 위해 척추의 인버스 바인드 포즈 행렬과 현재 포즈 행렬과의 곱을 매번 재계산하는 것은 불필요한 낭비이다. 단일 프레임에서 이 곱셈의 결과는 결코 변경되지 않기 때문이다. 그래서 행렬 팔레트라는 행렬 배열을 미리 생성해두면 불필요한 계산을 줄일 수 있다. 행렬 팔레트는 수채화 팔레트에서 노란색과 파란색을 2:1로 섞어 연두색을 만들 듯이 필요한 본의 인덱스와 가중치로 변환 행렬을 가져올 수 있다. 예를 들어 척추(Spine) 본의 인덱스가 1이라면 팔레트의 인덱스 1에는 다음과 같은 행렬이 들어 있다.

\[MatrixPalette[1] = [SpineInvBind][SpineCurrentPose]\]

만약 임의 버텍스\(v\)가 10, 15, 16번 본(bone)으로 부터 각각 0.2, 0.2, 0.6의 가중치로 영향을 받는다면 다음과 같이 계산할 수 있을 것이다.

\[v_{(InGlobalCurrentPose)} = v(0.2 \cdot MatrixPalette[10] + 0.2 \cdot MatrixPalette[15] + 0.6 \cdot MatrixPalette[16])\]


출처:

에이콘 출판 <Game Programming in C++>