전적으로 learnopengl.com의 글의 도움을 받았다.
https://learnopengl.com/Guest-Articles/2020/Skeletal-Animation
단순 렌더링 시뮬레이션이 아닌 게임 엔진을 만들어보는 것이 목표이다보니, 애니메이션 기능도 한번 넣어봐야겠다는 생각이 들었다.
다만 수요가 적어서 그런지 이번에도 정보가 부족했는데, 가장 괜찮아보였던게 상단에 링크된 글이다.
글의 내용을 그대로 따라가면서, 구현한 내용에 대해 적어보고자 한다.
시작하기 전 간단하게 짚고 넘어가자면, 애니메이션의 재생은 선형 보간을 사용한다.
게임 엔진을 다뤄보신 분들이라면 애니메이션의 키프레임에 대해 잘 알고 계실 것이다.
애니메이션의 모든 프레임이 키프레임으로 되어있지 않으므로, 그 사이는 선형 보간으로 자연스럽게 메워주는 것이다.
애니메이션 파일의 로드에도 assimp를 사용할 것인데, 자료구조는 다음과 같다.
모델을 로드했을 때 처럼 aiScene 포인터를 먼저 가져오면, 그 안에 Animation 배열이 있다.
각 aiAnimation은 duration, ticksPerSecond(애니메이션의 초당 프레임), channels가 있다.
channels는 aiNodeAnim의 배열인데, aiNodeAnim은 자기가 맡은 bone의 이름과 함께, 각 키프레임에 해당하는 position, rotation, scale 정보를 담고 있다.
그리고 animation이 담을 정보가 한 종류 더 있는데, 바로 weight이다.
weight는 bone이 vertex에 주는 영향에 대한 정보인데, 간단한 예시로 팔꿈치를 접으면 이두근이 튀어나오는 것을 표현한 것이다.
실제로는 한 버텍스에 영향을 미치는 bone은 여러개일 수 있는데, 이따가 나올 코드에서는 최대 4개로 정해두었다.
관련된 자료구조는 다음과 같다.
각 aiMesh는 aiBone 배열을 가지고 있는데, 여기있는 Bone들은 메쉬의 버텍스에 영향을 미치는 것들이다.
aiVertexWeight가 그 정도를 나타낸다.
aiBone의 멤버로 offsetMatrix도 있는데, 버텍스의 transform을 Model Space에서 Bone Space로 바꿔주는 행렬이다.
이제 코드에 대해서 살펴보자면,
코드는 전체적으로 위의 글의 것을 참고하였는데, 원문은 중간중간에 존재하지 않는 변수명을 쓰는 등 오류가 조금 보였고, 코드스타일도 내 방식과 좀 달라 조금씩 다르게 짜게 되었다.
전체 소스코드: https://github.com/sys010611/YsEngine
먼저 바뀐 Vertex Shader는 다음과 같다.
#version 330
layout (location = 0) in vec3 pos;
layout (location = 1) in vec2 tex;
layout (location = 2) in vec3 normal;
layout (location = 3) in ivec4 boneIds;
layout (location = 4) in vec4 weights;
const int MAX_BONES = 100;
const int MAX_BONE_INFLUENCE = 4;
uniform mat4 finalBonesMatrices[MAX_BONES];
out vec3 FragPos; // 월드 좌표계
out vec2 TexCoord;
out vec3 FragNormal;
uniform mat4 modelMat;
uniform mat4 PVM;
uniform mat3 normalMat;
void main()
{
vec4 totalPosition = vec4(0.f);
for(int i = 0; i < MAX_BONE_INFLUENCE; i++)
{
if(boneIds[i] == -1)
continue;
if(boneIds[i] >= MAX_BONES)
{
totalPosition = vec4(pos, 1.f);
break;
}
vec4 localPosition = finalBonesMatrices[boneIds[i]] * vec4(pos, 1.f);
totalPosition += localPosition * weights[i];
}
gl_Position = PVM * totalPosition;
FragPos = (modelMat * totalPosition).xyz;
TexCoord = tex;
FragNormal = normalMat * normal;
}
boneIds는 지금 이 버텍스에 영향을 미치는 bone의 index의 배열이다. (vec4를 아이템이 4개인 배열처럼 사용)
finalBoneMatrices는 모든 bone의 transformation 정보를 담고 있다.
for문 안에서 계산되는 localPosition은 버텍스의 원래 포지션을 애니메이션에 맞게 변환시켜준 결과이다.
버텍스에 영향을 미치는 bone이 여러개이므로 localPosition도 여러개가 나오는데(for문을 돌면서), 이걸 weight[i]의 비중으로 블렌딩해서 totalPosition을 버텍스의 최종 포지션으로 사용하는 것이다.
Vertex Shader로 쏴주는 정보로 boneIds, weights가 새로 생겼으므로 CPU쪽의 버텍스 정보도 이에 맞춰줘야 한다.
struct Vertex
{
Vertex() {}
Vertex(float px, float py, float pz)
{
Position.x = px; Position.y = py; Position.z = pz;
TexCoords.s = 0.f; TexCoords.t = 0.f;
Normal.x = 0.f; Normal.y = 0.f; Normal.z = 0.f;
}
glm::vec3 Position;
glm::vec2 TexCoords;
glm::vec3 Normal;
//bone indexes which will influence this vertex
int m_BoneIDs[MAX_BONE_INFLUENCE];
//weights from each bone
float m_Weights[MAX_BONE_INFLUENCE];
};
Vertex 구조체에 boneIDs, weights 배열을 추가해주었다.
void Mesh::CreateMesh(std::vector<Vertex>& vertices, std::vector<unsigned int>& indices, std::string name)
{
// ...
// ids
glVertexAttribIPointer(3, 4, GL_INT, sizeof(Vertex), (void*)offsetof(Vertex, m_BoneIDs));
glEnableVertexAttribArray(3);
// weights
glVertexAttribPointer(4, 4, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, m_Weights));
glEnableVertexAttribArray(4);
// ...
}
CreateMesh 함수에서는 GPU로 버텍스 정보를 쏴줄 때 3번, 4번 attribute로 boneId, weight를 쏴주도록 하였다.
그리고 각 Bone의 index, offset matrix를 담을 구조체 BoneInfo를 만들어주었다.
struct BoneInfo
{
int id; // finalBoneMatrices의 index
glm::mat4 offset;
};
Model 클래스에 추가된 코드는 다음과 같다.
Model.cpp:
// ...
// VBO, IBO에 담을 정보들을 구성한 뒤 쏴준다.
void Model::LoadMesh(aiMesh* mesh, const aiScene* scene)
{
std::vector<Vertex> vertices;
std::vector<unsigned int> indices;
for (size_t i = 0; i < mesh->mNumVertices; i++)
{
Vertex vertex;
InitVertexBoneData(vertex);
// position
vertex.Position = AssimpGLMHelpers::GetGLMVec(mesh->mVertices[i]);
// texture
if (mesh->mTextureCoords[0])
{
glm::vec2 vec;
vec.x = (mesh->mTextureCoords[0][i].x);
vec.y = (mesh->mTextureCoords[0][i].y);
vertex.TexCoords = vec;
}
else // 존재하지 않을 경우 그냥 0을 넣어주기
{
vertex.TexCoords = glm::vec2(0.f,0.f);
}
// normal (aiProcess_GenSmoothNormals를 적용했기 때문에 없을 수가 없다.)
vertex.Normal = AssimpGLMHelpers::GetGLMVec(mesh->mNormals[i]);
vertices.push_back(vertex);
}
// indices 채워주기
for (size_t i = 0; i < mesh->mNumFaces; i++)
{
aiFace face = mesh->mFaces[i];
for (size_t j = 0; j < face.mNumIndices; j++)
{
indices.push_back(face.mIndices[j]);
}
}
ExtractBoneWeightForVertices(vertices, mesh, scene);
Mesh* newMesh = new Mesh();
newMesh->CreateMesh(vertices, indices, mesh->mName.C_Str()); // GPU의 VBO, IBO로 버텍스 정보를 쏴준다.
meshList.push_back(newMesh);
// meshList에 mesh를 채워줌과 동시에, meshToTex에는 그 mesh의 materialIndex를 채워준다.
// 이렇게 meshList와 meshToTex를 나란히 채워줌으로써 mesh와 맞는 material을 손쉽게 찾을 수 있다.
meshToTex.push_back(mesh->mMaterialIndex);
}
void Model::InitVertexBoneData(Vertex& vertex)
{
for (int i = 0; i < MAX_BONE_INFLUENCE; i++)
{
vertex.m_BoneIDs[i] = -1;
vertex.m_Weights[i] = 0.f;
}
}
void Model::SetVertexBoneData(Vertex& vertex, int boneID, float weight)
{
for (int i = 0; i < MAX_BONE_INFLUENCE; i++)
{
if (vertex.m_BoneIDs[i] < 0)
{
// 하나만 채우고 도망가기
vertex.m_Weights[i] = weight;
vertex.m_BoneIDs[i] = boneID;
break;
}
}
}
void Model::ExtractBoneWeightForVertices(std::vector<Vertex>& vertices, aiMesh* mesh, const aiScene* scene)
{
for (int boneIndex = 0; boneIndex < mesh->mNumBones; boneIndex++)
{
int boneID = -1;
std::string boneName = mesh->mBones[boneIndex]->mName.C_Str();
if (boneInfoMap.find(boneName) == boneInfoMap.end())
{
BoneInfo boneInfo;
boneInfo.id = boneCounter;
auto offsetMat = mesh->mBones[boneIndex]->mOffsetMatrix;
boneInfo.offset = AssimpGLMHelpers::ConvertMatrixToGLMFormat(
mesh->mBones[boneIndex]->mOffsetMatrix
);
boneInfoMap[boneName] = boneInfo;
boneID = boneCounter;
boneCounter++;
}
else
{
boneID = boneInfoMap[boneName].id;
}
assert(boneID != -1);
auto weights = mesh->mBones[boneIndex]->mWeights;
int numWeights = mesh->mBones[boneIndex]->mNumWeights;
for (int weightIndex = 0; weightIndex < numWeights; weightIndex++)
{
int vertexId = weights[weightIndex].mVertexId;
float weight = weights[weightIndex].mWeight;
assert(vertexId <= vertices.size());
SetVertexBoneData(vertices[vertexId], boneID, weight);
}
}
}
// ...
// 관련 변수:
private:
std::map<std::string, BoneInfo> boneInfoMap;
int boneCounter = 0;
};
LoadMesh 마지막 즈음에 ExtractBoneWeightForVertices 함수를 호출했는데,
이게 각 버텍스에게 자신한테 영향을 미치는 bone들의 정보를 채우도록 하는 함수이다.
현재 mesh에 영향을 끼치는 bone들을 하나씩 훑으면서 boneInfoMap에 추가한다.
그리고 현재 bone이 어떤 버텍스(weights->mVertexId)에 얼마만큼의 영향(weights->mWeight)을 주는지를 파악하여 버텍스에 그 정보를 끼워넣어주는 것이다.
이렇게 함으로써 위의 Vertex 구조체의 m_boneIds, m_weights가 채워지고, 이걸 GPU로 쏴줄 수 있게 되는 것이다.
이후 내용은 다음 포스팅으로 이어진다.
'OpenGL > 개발 일지' 카테고리의 다른 글
[OpenGL] 게임 엔진 개발 (6) - Scene Hierarchy (0) | 2024.07.30 |
---|---|
[OpenGL] 게임 엔진 개발 (5) - Skeletal Animation (3) (0) | 2024.07.26 |
[OpenGL] 게임 엔진 개발 (4) - Skeletal Animation (2) (0) | 2024.07.26 |
[OpenGL] 게임 엔진 개발 (2) - GUI + Skybox (0) | 2024.07.18 |
[OpenGL] 게임 엔진 개발 (1) - 시작 + ImGui (0) | 2024.07.14 |