애니메이션

애니메이션

키워드
스키닝 애니메이션
 
 
CSEngine은 DAE파일을 불러오는 것과 동시에 애니메이션도 에셋화를 진행합니다. 애니메이션 에셋을 다른 모델링에 적용하는 것 역시 가능합니다.

구현을 위한 여정

애니메이션이 구동되는 영상
애니메이션은 정말 밑바닥부터 작업을 해야했기 때문에 오래걸린 파트 중 하나입니다.
평소 같았으면 DirectX Sample에 포함된 스키닝 예제와 FBXSDK를 이용해서 이론만 알고있다면 큰 무리없이 작업이 되었을거라 생각합니다.
하지만 구현하던 당시엔 환경이 너무 제약적이라 리눅스에 OpenGL만 돌아가는 상황이였습니다. 따라서 플랫폼과 msvc 버전마다 큰 차이가 있는 FBXSDK부담스러운 용량을 가진 Assimp은 사용하기 힘들었습니다.
결국 모델 파싱부터 애니메이션까지 모두 직접 구현해야겠다고 마음을 먹고 작업을 하였습니다.
다행스럽게도 구현은 성공적으로 마무리 되었습니다.
하지만 구현에 너무 치중한 나머지 여러 애니메이션이 돌 때 연산이나 함수 호출이 꽤 많기도 하고, std::map을 남용해버려서 결국 성능에 큰 문제를 야기하였습니다.
현재 모든 std::map으로 구현된 모든 자료구조와 l-value로 쓸데없이 재할당되는 현상도 수정했습니다. 덕분에 당시보다 훨씬 나아진 성능을 보여주고 있습니다.

파일 파싱 로직

notion image
파일의 파싱은 위 다이어그램과 같이 진행하게 됩니다. 분홍색은 파싱을 위한 임시 객체, 파란색은 리소스 객체, 녹색은 컴포넌트 객체 입니다.

애니메이션 작동 원리

notion image
애니메이션을 위해선 Animation 오브젝트를 가진 Animator Component, 뼈대의 행렬을 담은 Joint Component가 필요합니다.
현재 애니메이션의 로직은 아래와 같은 형식으로 돌아갑니다.
💡
아래의 코드는 로직을 돌 때 중요한 파트들만 적어놨습니다. 클래스의 구조는 소스코드를 참조해주시면 감사하겠습니다.
SGameObject 단계
일반적인 컴포넌트 사이클입니다. 나중에 애니메이션 매니저를 통해 따로 업데이트 사이클을 돌릴 예정입니다.
  1. SScene 클래스 내 오브젝트 모두 Tick함수 호출
    1. obj->Tick(elapsedTime); for (const auto& child : obj->GetChildren()) { TickGameObject(child, elapsedTime); }
  1. 오브젝트의 모든 컴포넌트를 업데이트
    1. for (const auto& component : m_components) { if (component == nullptr) continue; ... // 생략 if (component->GetIsEnable()) component->Tick(elapsedTime); // 컴포넌트의 업데이트 상태 함수 호출 }
AnimatorComponent 단계
애니메이터는 컴포넌트의 Tick 함수에 의해 업데이트 됩니다. 업데이트와 동시에 애니메이션 오브젝트에 저장된 데이터에 따라 뼈대를 보간하는 작업을 진행합니다.
  1. 애니메이션의 현재 시간을 동기화
    1. // 처음 시작한 시간과 경과된 시간을 기반으로 애니메이션의 시간을 동기화 합니다. m_animationTime += (elapsedTime - m_startTime) / 1000.f; float length = m_currentAnimation->GetLength(); if (m_animationTime > length) { m_animationTime -= (m_animationTime / length) * length; } m_startTime = elapsedTime;
  1. 애니메이션 오브젝트로부터 현 시간 사이의 키프레임들을 저장
    1. std::map<int, mat4> AnimatorComponent::calculateCurrentAnimationPose() const { std::vector<KeyFrame*> frames = getPreviousAndNextFrames(); const float progression = CalculateProgression(frames[0], frames[1]); return InterpolatePoses(frames[0], frames[1], progression); } // 현 시간을 기준으로 이전, 다음 키프레임을 받아오는 작업입니다. std::vector<KeyFrame*> AnimatorComponent::getPreviousAndNextFrames() const { auto allFrames = m_currentAnimation->GetKeyFrames(); KeyFrame* previousFrame = allFrames.front(); KeyFrame* nextFrame = allFrames.front(); allFrames.pop_front(); for (const auto& frame : allFrames) { nextFrame = frame; if (nextFrame->GetTimeStamp() > m_animationTime) { break; } previousFrame = frame; } std::vector<KeyFrame*> result; result.reserve(2); result.push_back(previousFrame); result.push_back(nextFrame); return result; }
  1. 애니메이션 키프레임 간 시간값을 진행도로 보간
    1. float AnimatorComponent::CalculateProgression(KeyFrame* previous, KeyFrame* next) const { float totalTime = next->GetTimeStamp() - previous->GetTimeStamp(); float currentTime = m_animationTime - previous->GetTimeStamp(); return currentTime / totalTime; }
  1. 애니메이션 키프레임을 현 시간에 맞게 보간
    1. std::map<int, mat4> AnimatorComponent::InterpolatePoses(KeyFrame* previousFrame, KeyFrame* nextFrame, float t) { std::map<int, mat4> currentPose; auto jointKeyFrames_prev = previousFrame->GetJointKeyFrames(); auto jointKeyFrames_next = nextFrame->GetJointKeyFrames(); for (const auto& frame : jointKeyFrames_prev) { const auto jointId = frame.first; JointTransform* previousTransform = frame.second; JointTransform* nextTransform = jointKeyFrames_next[jointId]; // 뼈대 간 보간작업 진행 JointTransform currentTransform = JointTransform::Interpolate(t, *previousTransform, *nextTransform); // 보간된 결과값 저장 currentPose.insert(std::pair<int, mat4>(jointId, currentTransform.GetLocalMatrix())); } return currentPose; }
  1. 보간된 값을 각 JointComponent로 전달
    1. void AnimatorComponent::applyPoseToJoints(std::map<int, mat4>& currentPose, JointComponent* joint, const mat4 parentTransform) { const auto& object = joint->GetGameObject(); const int jointId = joint->GetAnimationJointId(); const mat4& currentLocalTransform = currentPose[jointId]; // 현 단계 행렬 * 이전 단계 행렬 mat4&& currentTransform = currentLocalTransform * parentTransform; auto children = object->GetChildren(); for (const auto& child : children) { const auto& joint_component = child->GetComponent<JointComponent>(); if (joint_component != nullptr) // 재귀호출을 통해 트리 형식으로 행렬 곱셈 적용 applyPoseToJoints(currentPose, joint_component, currentTransform); } // 역행렬을 적용한 최종값을 JointComponent에 전달 currentTransform = joint->GetInverseTransformMatrix() * currentTransform; joint->SetAnimationMatrix(std::move(currentTransform)); }
이후 렌더링 단계
행렬의 최종 결과 값은 Joint Component에서 Mesh Component를 통해 Render Component에서 GPU로 전달됩니다.
  1. Mesh Component에서 값을 Vector로 변환
    1. void DrawableSkinnedMeshComponent::addJointsToVector(JointComponent* headJoint, std::vector<mat4>& matrix) const { int index = headJoint->GetID(); // 재귀 호출을 통해 vector에 행렬값을 전달 matrix[index] = headJoint->GetAnimationMatrix(); const auto& children = headJoint->GetGameObject()->GetChildren(); for (const auto& childJoint : children) { const auto& joint = childJoint->GetComponent<JointComponent>(); if (joint == nullptr) continue; addJointsToVector(joint, matrix); } }
  1. RenderComponent에서 행렬값 Material을 통해 GPU로 전달
    1. void RenderComponent::SetJointMatrix() const { m_material_clone->SetSkinningUniform(m_mesh->GetMeshID(), m_skinningMesh != nullptr ? m_skinningMesh->GetJointMatrix() : std::vector<mat4>()); }