Skip to main content

[Game Math] Chapter 17. 캐릭터: 게임에 생기를 불어넣는 기술

이득우의 게임 수학 책을 읽고 공부한 노트입니다.




스켈레탈 애니메이션 #

  • 스켈레탈 애니메이션(Skeletal animation)
    • 가상의 뼈대인 본(Bone) 을 캐릭터 메시에 심은 후, 이 본의 움직임에 맞춰서 캐릭터 메시를 변형시켜 애니메이션을 구현하는 방식이다.

  • 본의 정보를 메시에 추가하면, 메시는 본이 있는 메시(스킨드 메시: Skinned mesh)본이 없는 메시로 나뉘게 된다.
  • 본은 다음과 같은 정보들이 필요하다.
    • (1) 본을 식별할 고유한 이름 정보
    • (2) 본의 트랜스폼 정보 (크기, 회전, 이동이 가능하므로, 트랜스폼이 필요하다)
    • (3) 본의 처음 배치 정보 : 바인드 포즈(Bindpose)
enum class MeshType : UINT32
{
    Normal = 0,
    Skinned
};

class Bone
{
private:
    std::size_t _Hash = 0;
    std::string _Name;
    Transform _Transform;
    Transform _Bindpose;
};

  • 리깅(Rigging)
    • 본과 정점을 연결하는 작업이다.
    • 즉, 하나의 정점에 어떤 본들이 얼마나 영향을 미치는지 기록하는 것이다.
    • 여기서 한 정점에 영향을 주는 본들의 연결 정보를 웨이트(Weight) 라고 한다.
struct Weight
{
    std::vector<std::string> Bones; // 어떤 본들이 
    std::vector<float> Values;      // 얼마나 영향을 주는지 
};

  • Chapter 8에서 보았던 메시 정보를 스킨드 메시로 확장하고 리깅 정보를 추가하면 다음과 같이 되겠다.

    스킨드 메시 구조

  • 정점 버퍼

    순서 좌표 UV 좌표 연결된 본의 수 연결 정보
    0 (-0.5, -0.5, 0.5) (0.125, 0.75) 1 Left, 1
    1 (-0.5, 0.5, 0.5) (0.125, 0.875) 1 Left, 1
    2 (0.5, 0.5, 0.5) (0.25, 0.75) 1 Right, 1
    3 (0.5, -0.5, 0.5) (0.25, 0.875) 1 Right, 1
  • 인덱스 버퍼

    순서 삼각형 정점 순서
    0 0 0
    1 0 1
    2 0 2
    3 1 0
    4 1 2
    5 1 3
  • 본 목록

    이름 좌표
    Left (-0.5, 0, 0.5)
    Right (0.5, 0, 0.5)
bool GameEngine::LoadResources()
{
    // 사각 메시
    // ...

    // 스킨드 메시로 설정한다. 
    quadMesh.SetMeshType(MeshType::Skinned);

    auto& bones = quadMesh.GetBones();
    auto& connectedBones = quadMesh.GetConnectedBones();
    auto& weights = quadMesh.GetWeights();

    // 본 정보를 설정한다. 
    bones = {
        {"left", Bone("left", Transform(Vector3(-1.f, 0.f, 0.f) * halfSize))},
        {"right", Bone("right", Transform(Vector3(1.f, 0.f, 0.f) * halfSize))}
    };
    // 4개의 정점은 본이 각각 1개씩 연결되어 있다. 
    connectedBones = { 1, 1, 1, 1 };
    // 정점마다의 본 이름과 가중치를 설정한다. 
    weights = {
        { {"left"}, {1.f} },
        { {"left"}, {1.f} },
        { {"right"}, {1.f} },
        { {"right"}, {1.f} }
    };
    
    // ...
}

  • 게임 엔진은 애니메이션을 시간에 따라 변환된 값을 얻기 위해 키프레임(Keyframe) 데이터를 사용한다.
    • 주요 지점에 점을 찍어서 값을 지정하면, 나머지 값들은 보간을 통해 끊기지 않는 데이터를 제공한다.
  • 게임 로직과 본의 움직임 중 어떤 것을 먼저 처리해야 할까?
    • 게임 로직 결과에 맞는 적절한 애니메이션을 렌더링하기 위해서는
    • (1) 게임 로직이 완료된 후 (Update3D)
    • (2) 애니메이션 로직으로 본의 위치를 설정하고 (LateUpdate3D)
    • (3) 렌더링 해야할 것이다. (Render3D)

  • 애니메이션 로직을 구현하면 다음과 같다.
// 애니메이션 로직 : 본의 위치를 설정
void SoftRenderer::LateUpdate3D(float InDeltaSeconds)
{
    GameEngine& g = Get3DGameEngine();

    static float duration = 3.f;
    static float elapsedTime = 0.f;

    // 애니메이션을 위한 커브 생성
    // 시간에 따라 [0, 1]범위의 사인 파형을 생성하고 본을 움직이는 데 사용한다.  
    // (키프레임 데이터를 대체하기 위해 간단하게 sin 곡선을 사용했다.)
    
    // elapsedTime은 시간에 따라 0~3의 값이 나온다. 
    elapsedTime = Math::Clamp(elapsedTime + InDeltaSeconds, 0.f, duration);
    if (elapsedTime == duration)
    {
        elapsedTime = 0.f;
    }
    // 0~360 사이의 값으로 대응시킨다. 
    float sinParam = elapsedTime / duration * Math::TwoPI ;
    // -1~1 사이의 sin 결과 값을 0~1 사이의 값으로 대응시킨다. 
    float sinWave = (sinf(sinParam) + 1.f) * 0.5f;

    // 스킨드 메시가 아니면 종료한다. 
    GameObject& goPlayer = g.GetGameObject(PlayerGo);
    Mesh& m = g.GetMesh(goPlayer.GetMeshKey());
    if (!m.IsSkinnedMesh()) 
    {
        return;
    }

    const std::string leftBoneName("left");
    const std::string rightBoneName("right");
    Transform& leftBoneTransform = m.GetBone(leftBoneName).GetTransform();
    Transform& rightBoneTransform = m.GetBone(rightBoneName).GetTransform();

    // 기준 위치에서 추가로 움직인 본의 위치를 계산한다. 
    Vector3 deltaLeftPosition = Vector3::UnitX * -sinWave;
    Vector3 deltaRightPosition = Vector3::UnitX * sinWave;

    // 본의 바인드 포즈(최초 위치)를 기준으로 변경된 위치를 더해서 최종 위치를 계산한다. 
    leftBonePosition = m.GetBindPose(leftBoneName).GetPosition() + deltaLeftPosition;
    rightBonePosition = m.GetBindPose(rightBoneName).GetPosition() + deltaRightPosition;

    // 최종 위치를 설정한다. 
    leftBoneTransform.SetPosition(leftBonePosition);
    rightBoneTransform.SetPosition(rightBonePosition);
}

  • 정점 버퍼의 데이터를 행렬로 곱하기 전에, 본의 움직임을 반영해서 최종 위치를 계산해야 하겠다.
// 메시를 그리는 함수
void SoftRenderer::DrawMesh3D(const Mesh& InMesh, const Matrix4x4& InMatrix, const LinearColor& InColor)
{
    size_t vertexCount = InMesh.GetVertices().size();
    size_t indexCount = InMesh.GetIndices().size();
    size_t triangleCount = indexCount / 3;

    std::vector<Vertex3D> vertices(vertexCount);
    std::vector<size_t> indice(InMesh.GetIndices());
    for (size_t vi = 0; vi < vertexCount; ++vi)
    {
        vertices[vi].Position = Vector4(InMesh.GetVertices()[vi]);

        // 정점 변환 전에, 본의 움직임을 반영한다. 
        if (InMesh.IsSkinnedMesh())
        {
            Vector4 totalPosition = Vector4::Zero;
            Weight w = InMesh.GetWeights()[vi];
            
            // 메시에 연결된 본의 개수에 따라 반복
            for (size_t wi = 0; wi < InMesh.GetConnectedBones()[vi]; ++wi)
            {
                std::string boneName = w.Bones[wi];
                if (InMesh.HasBone(boneName) == false) continue;
                
                const Transform& boneTransform = InMesh.GetBone(boneName).GetTransform();
                
                // 본 - 바인드포즈 = 바인드 포즈로부터의 상대적 위치 
                Vector3 deltaPosition = boneTransform.GetPosition() - InMesh.GetBindPose(boneName).GetPosition();
                
                // 상대적 위치에 웨이트 값을 적용시킨다. 
                totalDeltaPosition += deltaPosition * w.Values[wi];
            }

            // 정점 위치에 반영한다. 
            vertices[vi].Position += Vector4(totalDeltaPosition, false);
        }

        if (InMesh.HasColor())
        {
            vertices[vi].Color = InMesh.GetColors()[vi];
        }

        if (InMesh.HasUV())
        {
            vertices[vi].UV = InMesh.GetUVs()[vi];
        }
    }

    // 정점 변환 진행
    VertexShader3D(vertices, InMatrix);

    // 삼각형 별로 그리기
    // ....
}



트랜스폼 계층 구조 #

  • 일반적으로 캐릭터를 구성하는 본은 부모-자식 관계의 계층 구조가 형성되어 있다. 그래서 부모가 움직이면 자식들도 움직이게 된다.
  • 부모가 없는 최상단의 트랜스폼을 루트(Root) 트랜스폼이라고 한다.
  • 이런 계층 구조에 따라서 트랜스폼 데이터는 다음과 같이 나뉠 수 있다.
    이름 설명
    로컬(local) 트랜스폼
    상대(relative) 트랜스폼
    부모로부터 상대적인 트랜스폼
    월드(world) 트랜스폼
    절대(absolute) 트랜스폼
    (계층 구조와 상관없이)
    자신이 속한 공간에서의 트랜스폼
class TransformComponent
{
public:
    bool SetRoot();
    TransformComponent& GetRoot();
    bool SetParent(TransformComponent& InTransform);
    
private:
    // 부모와 자식 목록을 보관한다. 
    TransformComponent* _ParentPtr = nullptr;
    std::vector<TransformComponent*> _ChildrenPtr;

    // 매번 월드 트랜스폼을 로컬 트랜스폼으로 계산해 주기 보다는 
    // 두 가지 트랜스폼을 모두 보관해 놓으면 좋겠다. 
    Transform _LocalTransform;
    Transform _WorldTransform;
};

트랜스폼 계층 구조의 변환 #

  • 부모나 자식의 트랜스폼 값이 변경되면 관련된 트랜스폼 값들을 조정해줘야 할 것이다. 사례를 통해 어떤 연산이 필요한지 알아보자.

  • 사례 1. 부모의 월드 트랜스폼 변경 시
    부모의 월드 트랜스폼 변경 시
  • 사례 2. 나의 로컬 트랜스폼 변경 시
    나의 로컬 트랜스폼 변경 시
  • 사례 3. 나의 월드 트랜스폼 변경 시
    나의 월드 트랜스폼 변경 시
  • 사례 4. 새로운 부모 설정 시
    새로운 부모 설정 시

  • 위와 같은 사례들을 통해 다음과 같은 두 가지 기능이 필요함을 알 수 있다.
    • (1) 부모의 월드 트랜스폼 정보, 나의 로컬 트랜스폼 정보 → 나의 월드 트랜스폼 계산
    • (2) 부모의 월드 트랜스폼 정보, 나의 월드 트랜스폼 정보 → 나의 로컬 트랜스폼 계산

(1) 로컬 트랜스폼으로부터 월드 트랜스폼의 계산 #

  • 내 월드 트랜스폼의 크기
    • 부모 월드 트랜스폼의 크기 $\vec{s}$와 내 로컬 트랜스폼의 크기 $\vec{s’}$를 곱하면 된다. $$\vec{s’_{world}} = \vec{s} * \vec{s’}$$
  • 내 월드 트랜스폼의 회전
    • 부모 월드 회전 사원수 $q$와 나의 로컬 회전 사원수 $q’$을 곱하면 된다. $$q’_{world} = q’ * q $$
  • 내 월드 트랜스폼의 이동
    • 내 월드 트랜스폼의 모델링 행렬은 다음과 같을 것이다. $$M_{world} = M_{parnet} \cdot M_{local}$$ $$ M_{world} = \begin{bmatrix} x_xs_x & y_xs_y & z_xs_z & t_x \\ x_ys_z & y_ys_y & z_ys_z & t_y \\ x_zs_x & y_zs_y & z_zs_z & t_y \\ s_zs_x & y_zs_y & z_zs_z & t_z \\ 0 & 0 & 0 & 1 \end{bmatrix} \cdot \begin{bmatrix} x’_xs’_x & y’_xs’_y & z’_xs’_z & t’_x \\ x’_ys’_z & y_ys’_y & z’_ys’_z & t’_y \\ x’_zs’_x & y’_zs’_y & z’_zs’_z & t’_y \\ s’_zs’_x & y’_zs’_y & z’_zs’_z & t’_z \\ 0 & 0 & 0 & 1 \end{bmatrix} $$
    • 이것의 4열만 월드 이동 값이므로 4열만 내적과 곱셈으로 정리하자. $$ \vec{t_{world}} = (\begin{bmatrix} x_x & y_x & z_x \\ s_y & y_y & z_y \\ x_z & y_z & z_z \end{bmatrix} \begin{bmatrix} t’_x \\ t’_y \\ t’_z \end{bmatrix} ) * \vec{s} + \vec{t} $$ $$ = (q \cdot \vec{t’} ) * \vec{s} + \vec{t} $$

// 로컬 트랜스폼 -> 월드 트랜스폼
FORCEINLINE constexpr Transform Transform::LocalToWorld(const Transform& InParentWorldTransform) const
{
    Transform result;
    
    // 크기
    result.SetScale(InParentWorldTransform.GetScale() * GetScale());
    
    // 회전
    result.SetRotation(InParentWorldTransform.GetRotation() * GetRotation());
    
    // 이동 
    result.SetPosition(InParentWorldTransform.GetPosition() + InParentWorldTransform.GetRotation() * (InParentWorldTransform.GetScale() * GetPosition()));
    
    return result;
}

(2) 월드 트랜스폼으로부터 로컬 트랜스폼의 계산 #

  • 먼저 부모의 월드 트랜스폼을 중심으로 나의 월드 트랜스폼을 해석해야 한다.
    • 이것은 Chapter 10에서 월드 공간에서 카메라 공간으로 중심을 바꾼 것과 유사하다.
    • 따라서 부모의 월드 트랜스폼에 역변환을 한 후에 나의 월드 트랜스폼을 곱하면 나의 로컬 트랜스폼이 만들어질 것이다.

  • 부모의 월드 트랜스폼을 역변환해보자.
  • 부모의 월드 트랜스폼의 크기의 역변환 $$ \vec{s^{-1}} = (\frac{1}{s_x}, \frac{1}{s_y}, \frac{1}{s_z}) $$
  • 부모의 월드 트랜스폼의 회전의 역변환
    • 켤레 사원 수 $ q^* $가 된다.
  • 부모의 월드 트랜스폼의 이동의 역변환
    • 부모 월드 트랜스폼의 모델링 행렬의 역행렬은 다음과 같을 것이다. $$ M^{-1} = (TRS)^{-1} = S^{-1}R^{-1}T^{-1} $$
    • 이것을 정리하면 다음과 같다. $$ \vec{t^{-1}} = (\begin{bmatrix} x_x & x_y & x_z \\ y_x & y_y & y_z \\ z_x & z_y & z_z \end{bmatrix} \begin{bmatrix} -t_x \\ -t_y \\ -t_z \end{bmatrix} ) * \vec{s^{-1}} $$ $$ = (q^* \cdot -\vec{t} ) * \vec{s^{-1}} $$

FORCEINLINE constexpr Transform Transform::Inverse() const
{
    Vector3 reciprocalScale = Vector3::Zero;
    Transform result;

    // 크기 
    // 트랜스폼 크기 값에 0이 있을 수도 있으므로
    // 이럴 때는 안전하게 역수로 구성된 벡터를 사용한다. 
    if (!Math::EqualsInTolerance(Scale.X, 0.f)) reciprocalScale.X = 1.f / Scale.X;
    if (!Math::EqualsInTolerance(Scale.Y, 0.f)) reciprocalScale.Y = 1.f / Scale.Y;
    if (!Math::EqualsInTolerance(Scale.Z, 0.f)) reciprocalScale.Z = 1.f / Scale.Z;
    result.SetScale(reciprocalScale);
    
    // 회전 
    result.SetRotation(Rotation.Inverse());
    
    // 이동 
    result.SetPosition(result.GetScale() * (result.GetRotation() * -Position));
    
    return result;
}

  • 이제 역변환을 구한 것에 나의 월드 트랜스폼을 곱하면 나의 로컬 트랜스폼이 만들어진다. $$ M_{local} = M_{parent}^{-1} \cdot M_{world} $$
  • 내 로컬 트랜스폼의 크기
    • 부모 월드 트랜스폼의 크기 역변환 $\vec{s^{-1}}$와 내 월드 트랜스폼의 크기 $\vec{s’}$를 곱하면 된다. $$\vec{s’_{local}} = \vec{s^{-1}} * \vec{s’}$$
  • 내 로컬 트랜스폼의 회전
    • 내 월드 회전 사원수 $q’$와 부모의 월드 회전 사원수 역변환 $q^*$을 곱하면 된다.
      $$q’_{local} = q^* * q’ $$
  • 내 월드 트랜스폼의 이동
    • 이전 식을 참고해서 구하면 다음과 같다. $$ \vec{t’_{local}} = (q^* \cdot \vec{t’}) * \vec{s^{-1}} + \vec{t^{-1}} $$

// 월드 트랜스폼 -> 로컬 트랜스폼
FORCEINLINE constexpr Transform Transform::WorldToLocal(const Transform& InParentWorldTransform) const
{
    // 부모 트랜스폼의 역변환 구하기 
    Transform invParent = InParentWorldTransform.Inverse();
    Transform result;

    // 크기 
    result.SetScale(invParent.GetScale() * GetScale());
    
    // 회전 
    result.SetRotation(invParent.GetRotation() * GetRotation());
    
    // 이동 
    result.SetPosition(invParent.GetPosition() + (invParent.GetRotation() * (invParent.GetScale() * GetPosition())));
    
    return result;
}

  • 다음은 캐릭터의 바인드 포즈 트랜스폼을 사용해서 애니메이션으로 움직인 본의 변화량을 반영해 최종 정점 위치를 계산하는 코드이다.
// 메시를 그리는 함수
void SoftRenderer::DrawMesh3D(const Mesh& InMesh, const Matrix4x4& InMatrix, const LinearColor& InColor)
{
    size_t vertexCount = InMesh.GetVertices().size();
    size_t indexCount = InMesh.GetIndices().size();
    size_t triangleCount = indexCount / 3;

    std::vector<Vertex3D> vertices(vertexCount);
    std::vector<size_t> indice(InMesh.GetIndices());
    for (size_t vi = 0; vi < vertexCount; ++vi)
    {
        vertices[vi].Position = Vector4(InMesh.GetVertices()[vi]);

        // 정점 변환 전에, 본의 움직임을 반영한다. 
        if (InMesh.IsSkinnedMesh())
        {
            Vector4 totalPosition = Vector4::Zero;
            Weight w = InMesh.GetWeights()[vi];
            
            // 해당 정점에 연결된 본의 개수에 따라 반복한다. 
            for (size_t wi = 0; wi < InMesh.GetConnectedBones()[vi]; ++wi)
            {
                std::string boneName = w.Bones[wi];
                if (InMesh.HasBone(boneName) == false) continue;
                
                const Bone& b = InMesh.GetBone(boneName);
                const Transform& t = b.GetTransform().GetWorldTransform();
                const Transform& bindPose = b.GetBindPose();
                
                // 바인드 포즈를 중심으로 본의 로컬 트랜스폼을 계산한다.
                Transform boneLocal = t.WorldToLocal(bindPose);
                
                // 바인드 포즈를 중심으로 정점의 로컬 좌표를 얻는다. 
                Vector3 localPosition = bindPose.WorldToLocalVector(vertices[vi].Position.ToVector3());
                
                // 본의 로컬 트랜스폼 행렬 * 정점의 로컬 좌표 = 바인드포즈에서 변화된 좌표 계산
                Vector3 skinnedLocalPosition = boneLocal.GetMatrix() * localPosition;
                
                // 바인드 포즈의 행렬 * 바인드포즈에서 변화된 좌표 = 모델링 공간으로 되돌리기 
                Vector3 skinnedWorldPosition = bindPose.GetMatrix() * skinnedLocalPosition;
                
                // 가중치를 반영한 후, 최종 위치에 누적시킨다. 
                totalPosition += Vector4(skinnedWorldPosition, true) * w.Values[wi];
            }

            // 정점 위치에 반영한다. 
            vertices[vi].Position = totalPosition;
        }

        if (InMesh.HasColor())
        {
            vertices[vi].Color = InMesh.GetColors()[vi];
        }

        if (InMesh.HasUV())
        {
            vertices[vi].UV = InMesh.GetUVs()[vi];
        }
    }

    // 정점 변환 진행
    VertexShader3D(vertices, InMatrix);

    // 삼각형 별로 그리기
    // ...
}