OpenGL/개발 일지

[OpenGL] 게임 엔진 개발 (4) - Skeletal Animation (2)

ciel45 2024. 7. 26. 17:52

앞선 과정을 통해 각 버텍스가 자신에게 영향을 주는 bone들의 정보를 모두 갖추게 되었다.

(몇번 index의 bone이 나에게 얼마만큼의 영향을 주고, 그 bone의 offset matrix는 어떻게 되는지)

 

이제 모든 키프레임 데이터를 가지며, 그 사이를 선형 보간하는 클래스 Bone을 만들 차례이다.

 

Bone.h:

#pragma once

#include <vector>
#include <string>

#include <glm/glm.hpp>
#include <glm\gtc\matrix_transform.hpp>
#include <glm\gtc\type_ptr.hpp>

#include "assimp/anim.h"

struct KeyPosition
{
	glm::vec3 position;
	float timeStamp;
};

struct KeyRotation
{
	glm::quat orientation;
	float timeStamp;
};

struct KeyScale
{
	glm::vec3 scale;
	float timeStamp;
};

class Bone
{
public:
	Bone(const std::string& name, int id, const aiNodeAnim* channel);
	void Update(float animationTime);

	glm::mat4 GetLocalTransform() { return localTransform; }
	std::string GetBoneName() const { return name; }
	int GetBoneID() { return id; }

	int GetPositionIndex(float animationTime);
	int GetRotationIndex(float animationTime);
	int GetScaleIndex(float animationTime);
	
private:
	float GetScaleFactor(float lastTimeStamp, float nextTimeStamp, float animationTime);
	glm::mat4 InterpolatePosition(float animationTime);
	glm::mat4 InterpolateRotation(float animationTime);
	glm::mat4 InterpolateScale(float animationTime);

	std::vector<KeyPosition> positions;
	std::vector<KeyRotation> rotations;
	std::vector<KeyScale> scales;
	int numPositions;
	int numRotations;
	int numScales;

	glm::mat4 localTransform;
	std::string name;
	int id;
};

 

Bone.cpp

#include "Bone.h"

#include <iostream>

#include "AssimpGLMHelpers.h"

Bone::Bone(const std::string& name, int id, const aiNodeAnim* channel) :
	name(name), id(id),	localTransform(1.f)
{
	numPositions = channel->mNumPositionKeys;
	for (int positionIndex = 0; positionIndex < numPositions; positionIndex++)
	{
		aiVector3D aiPosition = channel->mPositionKeys[positionIndex].mValue;
		float timeStamp = channel->mPositionKeys[positionIndex].mTime;
		KeyPosition data;
		data.position = AssimpGLMHelpers::GetGLMVec(aiPosition);
		data.timeStamp = timeStamp;
		positions.push_back(data);
	}

	numRotations = channel->mNumRotationKeys;
	for (int rotationIndex = 0; rotationIndex < numRotations; rotationIndex++)
	{
		aiQuaternion aiRotation = channel->mRotationKeys[rotationIndex].mValue;
		float timeStamp = channel->mRotationKeys[rotationIndex].mTime;
		KeyRotation data;
		data.orientation = AssimpGLMHelpers::GetGLMQuat(aiRotation);
		data.timeStamp = timeStamp;
		rotations.push_back(data);
	}

	numScales = channel->mNumScalingKeys;
	for (int scaleIndex = 0; scaleIndex < numScales; scaleIndex++)
	{
		aiVector3D aiScale = channel->mScalingKeys[scaleIndex].mValue;
		float timeStamp = channel->mScalingKeys[scaleIndex].mTime;
		KeyScale data;
		data.scale = AssimpGLMHelpers::GetGLMVec(aiScale);
		data.timeStamp = timeStamp;
		scales.push_back(data);
	}
}

void Bone::Update(float animationTime)
{
	glm::mat4 T = InterpolatePosition(animationTime);
	glm::mat4 R = InterpolateRotation(animationTime);
	glm::mat4 S = InterpolateScale(animationTime);
	localTransform = T*R*S;
}

int Bone::GetPositionIndex(float animationTime)
{
	for (int i = 0; i < numPositions - 1; i++)
	{
		if (animationTime < positions[i + 1].timeStamp)
			return i;
	}
	assert(0);
}

inline int Bone::GetRotationIndex(float animationTime)
{
	for (int i = 0; i < numRotations - 1; i++)
	{
		if (animationTime < rotations[i + 1].timeStamp)
			return i;
	}
	assert(0);
}

int Bone::GetScaleIndex(float animationTime)
{
	for (int i = 0; i < numScales - 1; i++)
	{
		if (animationTime < scales[i + 1].timeStamp)
			return i;
	}
	assert(0);
}

float Bone::GetScaleFactor(float lastTimeStamp, float nextTimeStamp, float animationTime)
{
	float scaleFactor = 0.f;
	float midWayLength = animationTime - lastTimeStamp;
	float framesDiff = nextTimeStamp - lastTimeStamp;
	scaleFactor = midWayLength / framesDiff;
	return scaleFactor;
}

glm::mat4 Bone::InterpolatePosition(float animationTime)
{
	if(numPositions == 1)
		return glm::translate(glm::mat4(1.f), positions[0].position);

	int p0Index = GetPositionIndex(animationTime);
	int p1Index = p0Index+1;
	float scaleFactor = GetScaleFactor(positions[p0Index].timeStamp,
		positions[p1Index].timeStamp, animationTime);
	glm::vec3 finalPosition = glm::mix(positions[p0Index].position, 
		positions[p1Index].position, scaleFactor);
	return glm::translate(glm::mat4(1.f), finalPosition);
}

glm::mat4 Bone::InterpolateRotation(float animationTime)
{
	if (numRotations == 1)
	{
		auto rotation = glm::normalize(rotations[0].orientation);
		return glm::mat4_cast(rotation);
	}
		

	int p0Index = GetRotationIndex(animationTime);
	int p1Index = p0Index + 1;
	float scaleFactor = GetScaleFactor(rotations[p0Index].timeStamp,
		rotations[p1Index].timeStamp, animationTime);
	glm::quat finalRotation = glm::slerp(rotations[p0Index].orientation,
		rotations[p1Index].orientation, scaleFactor);
	return glm::mat4_cast(finalRotation);
}

glm::mat4 Bone::InterpolateScale(float animationTime)
{
	if (numScales == 1)
		return glm::scale(glm::mat4(1.f), scales[0].scale);

	int p0Index = GetScaleIndex(animationTime);
	int p1Index = p0Index + 1;
	float scaleFactor = GetScaleFactor(scales[p0Index].timeStamp,
		scales[p1Index].timeStamp, animationTime);
	glm::vec3 finalScale = glm::mix(scales[p0Index].scale,
		scales[p1Index].scale, scaleFactor);
	return glm::scale(glm::mat4(1.f), finalScale);
}

 

생성자는 그냥 데이터 로드하는게 전부이고, Update함수가 핵심이다.

 

animationTime을 인자로 받으면 그걸 이용해 translation, rotation, scale을 선형보간하여 그 변환 매트릭스를 localTransform에 저장한다.

 

그 선형보간하는 함수가 InterpolatePosition/Rotation/Scale인데, 3개 다 비슷비슷하게 scaleFactor를 사용하는 것이 보일 것이다.

 

GetScaleFactor 함수가 하는 일은 다음 그림과 같다.

출처: learnopengl.com

이전 키프레임과 다음 키프레임 사이 어느 지점인지를 찝어서 그걸 뱉어주도록 할 건데,

그러기 위해서는  (현재 지점-이전 키프레임 지점) / 키프레임 간 거리 를 계산해주면 된다.

 

현재 지점 - 이전 키프레임 지점이 코드의 midWayLength에 해당하고,

키프레임 간 거리가 코드의 framesDiff에 해당하는 것이다.

 

이렇게 얻은 scaleFactor를 InterpolatePosition/Rotation/Scale함수에서 glm::mix의 3번째 인자로 넣은 것을 볼 수 있는데,

glm::mix는 유니티/언리얼의 Lerp() 함수랑 99% 같다.

인자로 start, end, alpha를 넣어주면 alpha 지점에 해당하는 보간 값을 뱉어주는 식.

 

 

다음은 이 Bone 정보들을 하나로 합친 애니메이션을 관리하는 Animation 클래스이다.

Animation.h

#pragma once

#include <string>
#include <vector>
#include <map>

#include <glm/glm.hpp>
#include <glm\gtc\matrix_transform.hpp>
#include <glm\gtc\type_ptr.hpp>

#include "CommonValues.h"

class Model;
class Bone;
struct aiAnimation;
struct aiNode;

class Animation
{
public:
	Animation() = default;

	Animation(const std::string& animationPath, Model* model);

	~Animation() = default;

	Bone* FindBone(const std::string& name);

	float GetTicksPerSecond() { return ticksPerSecond; }
	float GetDuration() { return duration; }
	const AssimpNodeData& GetRootNode() { return rootNode; }
	const std::map<std::string, BoneInfo>& GetBoneIDMap() { return boneInfoMap; }

private:
	void ReadMissingBones(const aiAnimation* animation, Model& model);
	void ReadHierarchyData(AssimpNodeData& dest, const aiNode* src);

	float duration;
	int ticksPerSecond;
	std::vector<Bone*> bones;
	AssimpNodeData rootNode;
	std::map<std::string, BoneInfo> boneInfoMap;
};

 

Animation.cpp

#include "Animation.h"

#include <iostream>

#include <assimp/Importer.hpp>
#include <assimp/scene.h>
#include <assimp/postprocess.h>

#include "Bone.h"
#include "Model.h"
#include "AssimpGLMHelpers.h"

Animation::Animation(const std::string& animationPath, Model* model)
{
	Assimp::Importer importer;
	const aiScene* scene = importer.ReadFile(animationPath, aiProcess_Triangulate);

	if (!scene)
	{
		std::cout << animationPath << " Animation 로드 실패 : " << importer.GetErrorString() << std::endl;
		return;
	}

	assert(scene && scene->mRootNode);
	aiAnimation* animation = scene->mAnimations[0];
	duration = animation->mDuration;
	ticksPerSecond = animation->mTicksPerSecond;
	ReadHierarchyData(rootNode, scene->mRootNode);
	ReadMissingBones(animation, *model);
}

Bone* Animation::FindBone(const std::string& name)
{
	auto iter = std::find_if(bones.begin(), bones.end(),
		[&](const Bone* bone) 
		{
			return bone->GetBoneName() == name;
		}
	);
	if(iter == bones.end()) 
		return nullptr;
	else
		return (*iter);
}

void Animation::ReadMissingBones(const aiAnimation* animation, Model& model)
{
	int size = animation->mNumChannels;

	auto& boneInfoMap = model.GetBoneInfoMap();
	int& boneCount = model.GetBoneCount();

	for (int i = 0; i < size; i++)
	{
		aiNodeAnim* channel = animation->mChannels[i];
		std::string boneName = channel->mNodeName.data;

		if (boneInfoMap.find(boneName) == boneInfoMap.end())
		{
			boneInfoMap[boneName].id = boneCount;
			boneCount++;
		}
		this->bones.push_back(new Bone(channel->mNodeName.data, boneInfoMap[boneName].id, channel));
	}

	this->boneInfoMap = boneInfoMap;
}

void Animation::ReadHierarchyData(AssimpNodeData& dest, const aiNode* src)
{
	assert(src);

	dest.name = src->mName.data;
	dest.transformation = AssimpGLMHelpers::ConvertMatrixToGLMFormat(src->mTransformation);
	dest.childcount = src->mNumChildren;

	for (int i = 0; i < src->mNumChildren; i++)
	{
		AssimpNodeData newData;
		ReadHierarchyData(newData, src->mChildren[i]);
		dest.children.push_back(newData);
	}
}


// CommonValues.h 내부 AssimpNodeData
struct AssimpNodeData
{
	glm::mat4 transformation;
	std::string name;
	int childcount;
	std::vector<AssimpNodeData> children;
};

 

생성자를 통해 모든 작업이 끝나는데,

먼저 Model Loading 때와 마찬가지로 Assimp::Importer로 scene을 가져온다.

 

duration, ticksPerSecond를 가져온 뒤 ReadHierarchyData 함수를 호출하는데, 

aiNode들을 순회하면서 각 노드들이 담고 있는 정보를 직접 만든 AssimpNodeData 구조체에 복사하는 것이다.

다시 한번 aiNode가 담고 있는 정보를 상기해보자면, bone의 이름, 변환 행렬, 부모 노드(여기서는 굳이 저장하지 않음), 자식 노드 목록임을 볼 수 있다.

 

그 외 ReadMissingBones 함수는 혹시나 애니메이션에는 있는 bone이 Model에는 없는 경우를 대비하는 것인데, 실제로 이럴 일은 거의 없다.

 

 

다음은 Animation의 재생을 맡는 Animator 클래스이다.

Animator.h

#pragma once
#include <vector>

#include <glm/glm.hpp>
#include <glm\gtc\matrix_transform.hpp>
#include <glm\gtc\type_ptr.hpp>

#include "CommonValues.h"

class Animation;

class Animator
{
public:
	Animator(Animation* animation);

	void UpdateAnimation(float deltaTime);
	void PlayAnimation(Animation* pAnimation);
	void CalculateBoneTransform (const AssimpNodeData* node, glm::mat4 parentTransform);
	std::vector<glm::mat4> GetFinalBoneMatrices();

private:
	std::vector<glm::mat4> finalBoneMatrices;
	Animation* currentAnimation;
	float currentTime;
	float deltaTime;
};

 

Animator.cpp

#include "Animator.h"

#include <iostream>

#include "Animation.h"
#include "Bone.h"
#include "CommonValues.h"

Animator::Animator(Animation* animation)
{
	currentTime = 0.f;
	currentAnimation = animation;

	finalBoneMatrices.reserve(MAX_BONE_COUNT);

	for (int i = 0; i < MAX_BONE_COUNT; i++)
	{
		finalBoneMatrices.push_back(glm::mat4(1.f));
	}
}

void Animator::UpdateAnimation(float deltaTime)
{
	this->deltaTime = deltaTime;
	if (currentAnimation)
	{
		currentTime += currentAnimation->GetTicksPerSecond() * deltaTime;
		currentTime = fmod(currentTime, currentAnimation->GetDuration());
		CalculateBoneTransform(&currentAnimation->GetRootNode(), glm::mat4(1.f));
	}
}

void Animator::PlayAnimation(Animation* pAnimation)
{
	currentAnimation = pAnimation;
	currentTime = 0.f;
}

void Animator::CalculateBoneTransform(const AssimpNodeData* node, glm::mat4 parentTransform)
{
	std::string nodeName = node->name;
	glm::mat4 nodeTransform = node->transformation;

	Bone* bone = currentAnimation->FindBone(nodeName);

	if (bone)
	{
		bone->Update(currentTime);
		nodeTransform = bone->GetLocalTransform();
	}

	glm::mat4 globalTransformation = parentTransform * nodeTransform;

	auto boneInfoMap = currentAnimation->GetBoneIDMap();
	if (boneInfoMap.find(nodeName) != boneInfoMap.end())
	{
		int index = boneInfoMap[nodeName].id;
		glm::mat4 offset = boneInfoMap[nodeName].offset;
		assert(!std::isnan(offset[0][0]));
		finalBoneMatrices[index] = globalTransformation * offset;
	}

	for (int i = 0; i < node->childcount; i++)
	{
		CalculateBoneTransform(&node->children[i], globalTransformation);
	}
}

std::vector<glm::mat4> Animator::GetFinalBoneMatrices()
{
	return finalBoneMatrices;
}

 

생성자에서 animation을 받아 currAnimation으로 지정해두고, finalBoneMatrices를 단위행렬로 채워놓았다.

 

UpdateAnimation이 애니메이션 재생을 위해 외부에서 호출하게될 함수로, deltaTime을 받아 현재 시간을 계산한다.

if문 내부 2번째 줄의 fmod는 float 범위에서 모듈러 연산을 한다는 의미이다. currentTime이 애니메이션의 길이를 넘어서지 않도록 하는 것이다.

 

 

 

이후 CalculateBoneTransform을 호출하는데, 이게 핵심이라고 할 수 있다.

bone의 이름과 transformation을 가져오고, 이 bone이 현재 애니메이션에서 쓰이는 bone이라면, transformation을 업데이트 후, 그 업데이트된 변환행렬을 nodeTransform에 담는다.

 

그 다음 globalTransformation을 계산하는데, 이 시점에서 nodeTransform / parentTransform / globalTransform 각각의 의미가 매우 헷갈리니 좀 정리해보려고 한다.

 

nodeTransform : bone 하나가 자신의 bone space에서 정의하는 변환행렬이다.

parentTransform : root bone부터 지금 나(bone)에게 오기 전까지의 모든 변환행렬이 합성되어있는 행렬이다.

globalTransform : 누적되어 온 parentTransform에 nodeTransform을 곱한 것으로, root bone space, 즉 model space에서 유효한 나에 대한 변환행렬을 의미한다.

 

CalculateBoneTransform이 rootBone을 시작으로 쭉쭉 재귀호출되기 때문에 이렇게 되는 것이다.

 

globalTransformation을 finalBoneMatrices에 저장할 때는 그냥 저장하지 않고 offset을 곱해주는데, 

offset 행렬의 역할을 다시 상기해보면, 버텍스 좌표를 model space에서 bone space로 바꿔주는 것이다.

 

globalTransformation * offset의 의미를 다시 한번 생각해보자면,

offset을 통해 버텍스를 model space에서 bone space로 바꿔준 뒤, 거기에 globalTransformation을 곱해줌에 따라 bone의 transformation을 적용해가면서 다시 bone space에서 model space로 거꾸로 돌아오는 것이다.

즉 이것이 model space에서의 버텍스의 적절한 변환 행렬로, 이를 finalBoneMatrix로써 저장하는 것이다.

 

 

즉 모델의 원점에서 이 bone이 어느 위치에 있어야 하는지 offset으로 맞춰준 뒤, 거기에 globalTransformation을 이용해 bone을 움직여주는 것까지 해주는 행렬을 finalBoneMatrices에 채워넣는 것이다.

 

여기서 finalBoneMatrices의 index는 bone의 id를 그대로 사용해준 것을 볼 수 있다.

 

여기까지 해서 전체적인 애니메이션 코드가 끝이고, 다음 포스팅에서는 애니메이션을 적용하는 내용을 다룰 것이다.