[DirectX11] FBX, CPU Skinning
1. 개요
https://yeoul0714.tistory.com/55
[DirectX11] FBX SDK적용, 컴파일 옵션 /Md /Mt
1. 개요우리는 지금까지의 프로젝트에서 OBJ Parser를 만들고 그것을 Rendering Pipeline에 연결해서 OBJ를 읽고 Rendering하는 시스템을 구축했습니다. https://yeoul0714.tistory.com/22 [DX11] Obj 파일 Parsing후 Pipe lin
yeoul0714.tistory.com
지난번엔 FBX파일을 읽어오는 SDK를 적요하는 방법에 대해서 알아보았습니다.
이번엔 FBX파일을 Rendering Pipeline에 맞게 데이터를 가공해 Rendering하고, CPU Skinning에 대해서 알아볼것입니다.
2. Bone의 계층구조
FBX 모델의 Bone들은 기본적으로 계층구조를 가지고있습니다.
계층구조를 가지는 것이 우리 인간의 골격 형태를 모방하여 표현하기에 적절하기 때문에 이러한 형태를 가지고 있습니다.
용어
- 루트 본(Root Bone): 계층 구조의 최상위에 위치한 본으로, 일반적으로 골반(pelvis)이나 힙(hip)이 됩니다.
- 리프 본(Leaf Bone): 자식이 없는 계층 구조의 끝에 위치한 본입니다 (예: 손가락 끝, 발가락 끝).
어째서 계층구조로 표현되어야 하나에 대해 고민을 해보도록 하겠습니다.
만약 인간이 어깨를 크게 움직이게 된다면 상박, 하박, 팔, 손바닥, 손가락 모두 움직이게 될것입니다.
이를 계층구조로 비유를 해본다면 가장 상위 bone인 어깨의 bone 이 움직였기에 어깨의 자식 bone인
상박, 하박, 손바닥, 손가락 모두 움직였다고 생각하는 것이 가능합니다.
이것에 대한 구현을 위해서는 Matrix(행렬)에 대한 이해가 필요합니다.
여기서 새로운 개념이 또다시 등장합니다.
바로 GlobalTransform과 LocalTransform입니다.
바로 아래와 같은 형태로 구하게 됩니다.
FbxAMatrix GlobalTransform = BoneNode->EvaluateGlobalTransform();
FbxAMatrix LocalTransform = BoneNode->EvaluateLocalTransform();
타입들은 전부 SDK에서 제공해주는 타입입니다.
LocalTransform
- LocalTransform은 부모 Bone에 대한 자식의 상대적인 위치를 표현하는 행렬입니다.
- 로컬 변환 행렬은 부모 본에 대한 자식 본의 상대적인 위치, 회전, 크기를 표현하는 4x4 행렬입니다.
- 부모 본의 좌표계를 기준으로 정의됩니다.
한마디로 부모 Bone에 대한 자식 Bone의 상대적 위치입니다.
GlobalTransform
- GlobalTransform은 이름대로 모델공간에서의 본의 위치를 표현할때 사용합니다.
- 글로벌 변환 행렬은 루트 본부터 시작하여 계층 구조를 따라 모든 부모 본의 로컬 변환을 누적한 결과입니다.
해당 본까지의 경로에 있는 모든 본의 로컬 변환을 누적한 결과가
GlobalTransform이 된다는 점은 직관적으로 이해가 어려울 수 있습니다.
좌표계 변환에 대한 이해가 충분해야 이해할 수 있는 부분임으로 이해가 어려운 분들은 이부분을 공부해보시길 바랍니다.
혹은 이해가 끝까지 어렵다면 수학식에 너무 매달리기 보다는
각 Bone의 GlobalTransform은 해당 bone까지의 경로에 있는 Local Transform의 누적이라고 받아들이세요
이런식으로 부모의 로컬pose를 계속 곱해간다면 결국 Global에서의 좌표변환 행렬을 알 수 있습니다.
결국 Bone이 움직이게 되면 Local Matrix가 바뀌게 되고 결국엔 Global Matrix가 바뀌게 되어서
모델 공간에서의 위치를 알 수 있게 됩니다.
3.CPU Skinning
3-1. 필요개념
CPU Skinning은 캐릭터 애니메이션에서 메시 변형을 CPU에서 계산하는 과정입니다.
다시말하자면 관절에 따라서 캐릭터가 움직이게 되는데 움직이며 Vertex값들이 바뀌게 되는데
그것이 자연스럽게 보이도록 계산해주는 과정이라고 생각하시면 됩니다.
우리는 Skinning Matrix를 통해서 캐릭터의 신체부위가 움직였을때의 위치를 추적할 것입니다.
Skinning Matrix를 계산하기 위해서는 알아야할 개념들이 있습니다.
InverseBindPose
이 행렬은 바인드 포즈에서의 월드 좌표를 본 로컬 좌표로 변환시키는 행렬입니다. ( Model -> Bone)
CurrentGlobalTransform
현재 애니메이션 상태에서 본 로컬 좌표를 월드 좌표로 변환하는 행렬입니다. (Bone -> Model)
이 두개를 곱하게되면 현재의 위치를 알 수 있게 됩니다.
다시 설명하자면 InverseBindPose가 어떠한 정점에 적용되면
해당 정점은 Bone을 기준으로한 상대적인 위치 좌표를 가지게 됩니다.
이 정점은 Bone을 기준으로 한 상대적인 위치기 때문에 해당 Bone이 움직이게 되더라도 해당 좌표는 동일 합니다.
그렇게 움직이게 되면 CurrentGlobalTransform이 변화되었을 것이고, 이제 이 행렬과 Bone을 기준으로한 좌표를 곱해줍니다.
그러면 움직인 공간에서의 Model좌표가 나오게 됩니다.
3-2. 코드구현, Trouble Shouting
FbxAMatrix MeshTransform; // Mesh의 바인드 시점의 Global Transform
Cluster->GetTransformMatrix(MeshTransform); // 메시의 변환 행렬 (기준점)
FbxAMatrix LinkTransform; // Bone의 바인드 시점의 Global Transform
Cluster->GetTransformLinkMatrix(LinkTransform); // 본의 바인드 포즈 행렬
FbxAMatrix InverseBindMatrix = LinkTransform.Inverse()*MeshTransform;
for (int i = 0; i < 4; i++) {
for (int j = 0; j < 4; j++) {
NewBone.InverseBindPoseMatrix.M[i][j] = static_cast<float>(InverseBindMatrix.Get(i, j));
}
}
NewBone.SkinningMatrix = NewBone.InverseBindPoseMatrix * NewBone.GlobalTransform;
코드에서의 SkinningMatrix는 다음과 같이 구하고 있습니다.
아마 제가 설명한 부분과 다른부분이 있을텐데요 그것은 바로 MeshTransform까지 고려중인 부분입니다.
원래는 LinkTransform의 역행렬만 있으면 됩니다. (이것이 바로 바인드 포즈의 월드 좌표를 본의 로컬좌표로 바꾸는 행렬입니다.)
그러나 어떠한 FBX파일은 Bone과 Mesh의 좌표계가 다른경우가 있습니다. (실수등으로 인하여)
그래서 MeshTransform까지 고려해서 혹시모를 실수를 방지하였습니다.
실제로 개발중에 MeshTransform을 고려하지 않아서 Bone의 위치와 Mesh의 위치가 맞지 않는 현상이 있었습니다.
노란색 블록은 Bone이고, 회색 사람형체가 Mesh입니다.
3-2. Trouble Shouting2
아래 코드는 관절이 움직일때 Vertex의 위치변화를 계산하는 코드입니다.
우리가 처음 구했던 Skinning Matrix를 사용하고있습니다.
한 Vertex는 최대 4개의 Bone의 영향을 받게 되어있습니다.
위에는 설명하지 않았지만 Bone에는 가중치가 있어서 더욱 영향을 많이 주는 Bone이 있습니다. (가중치들의 합은 1)
코드설명은 그렇고 Trouble Shouting에 대해 설명하겠습니다.
FVector FSkeletalVertex::SkinVertexPosition(const TArray<FBone>& bones) const
{
FVector result = {0.0f, 0.0f, 0.0f};
for (int i = 0; i < 4; ++i) {
int boneIndex = BoneIndices[i];
float weight = BoneWeights[i];
if (weight > 0.0f && boneIndex >= 0 && boneIndex < bones.Num()) {
const FMatrix& SkinMat = bones[boneIndex].SkinningMatrix;
FVector transformed = SkinMat.TransformPosition(Position.xyz());
result = result + (transformed * weight);
}
}
return result;
}
void FSkeletalVertex::SkinningVertex(const TArray<FBone>& bones)
{
Position = FVector4(SkinVertexPosition(bones), 1.0f);
}
사실 이 코드의 로직은 아무런 문제가 없습니다.
아래 코드는 위의 코드를 실행하는 바깥쪽 부분인데 이곳에서 핵심이 있습니다.
그것은 바로 Vertices.Position을 Original로 초기화 해주는 부분입니다. (Original은 초기 바인드 포즈에서의 Vertex)
초기 Bind Pose를 기준으로 모든것이 가정되어 있는 상태에서 이것을 초기화해주지 않는다면
이미 변화된 상태에서 Skinning을 계산하기 때문에 값이 계속해서 중첩되고 우리가 원하지 않는 결과가 나오게 됩니다.
oid USkeletalMesh::UpdateSkinnedVertices()
{
if (SkeletalMeshRenderData.Vertices.Num() <= 0)
return;
if (OriginalVertexPositions.Num() == SkeletalMeshRenderData.Vertices.Num())
{
for (int i = 0; i < SkeletalMeshRenderData.Vertices.Num(); i++)
{
SkeletalMeshRenderData.Vertices[i].Position = OriginalVertexPositions[i];
}
}
// 스키닝 적용
for (auto& Vertex : SkeletalMeshRenderData.Vertices)
{
Vertex.SkinningVertex(SkeletalMeshRenderData.Bones);
}
FRenderResourceManager* renderResourceManager = GEngineLoop.Renderer.GetResourceManager();
ID3D11Buffer* VB = renderResourceManager->CreateDynamicVertexBuffer<FSkeletalVertex>(SkeletalMeshRenderData.Vertices);
renderResourceManager->AddOrSetVertexBuffer(SkeletalMeshRenderData.Name, VB);
}
저 초기화를 해주지 않는다면 아래와 같은 괴생명체가 탄생하게 됩니다.
아래 사진은 인위적으로 만든것이 아닌 이 부분을 개발할때 실제로 탄생했던 오브젝트들입니다.
결국 이러한 부분을 적절히 수정했고, 원하는 결과를 얻을 수 있었습니다.
수학적 부분이 많이 들어가서 이해하기 쉽지 않은 파트였지만 애니메이션 작업하기에 앞서 근간이 되는 소중한 시간이었습니다.
https://www.youtube.com/watch?v=8S-QrK7lWyU&t=1s