[목차]
- assimp 라이브러리 이식하기
- assimp 기능을 활용하여 Mesh 로드하기
- 애니메이션 로드하기
- 애니메이션 행렬 계산하기
- 애니메이션 스키닝 계산하기
[assimp 라이브러리 이식하기]
assimp(Open Asset Import) 라이브러리는 다양한 3D 애셋에 대해 API를 제공하여, 3D 모델을 임포트하기 위해 사용되는 오픈 소스 라이브러리입니다. 옛날에 FBX 라이브러리를 활용해본적이 있는데, 조금 복잡하다고 느껴서 조금 더 간단한 라이브러리가 없나 찾아보다가 알게 되었습니다.
https://github.com/assimp/assimp
GitHub - assimp/assimp: The official Open-Asset-Importer-Library Repository. Loads 40+ 3D-file-formats into one unified and clea
The official Open-Asset-Importer-Library Repository. Loads 40+ 3D-file-formats into one unified and clean data structure. - GitHub - assimp/assimp: The official Open-Asset-Importer-Library Reposit...
github.com
파일을 받은 다음 CMake를 통해 lib, dll 파일을 추출하고, inluce 파일을 가져와서 사용하면 됩니다.
위의 링크에서 파일을 받은 다음 CMake를 통해 lib과 dll을 추출하고, include 파일을 가져와서 사용하면 됩니다. 해당 프로젝트에서는 아래의 폴더 구조와 같이 파일들을 위치시켰습니다.
[DirectX11_RenderingPipeline 프로젝트 폴더]
ㄴ[DirectX11_Base]
[DirectX11_Contents]
[DirectX11_Extension]
[DirectX11_RenderingPipeline]
[Shader]
[ThirdParty]
ㄴ[DirectTex]
[Assimp]
ㄴ[inc]
ㄴ[assimp]
ㄴ[많은 파일들]
ㄴ[lib]
ㄴ[x64]
ㄴ[Debug]
ㄴassimp-vc143-mtd.dll
assimp-vc143-mtd.lib
assimp-vc143-mtd.exp
assimp-vc143-mtd.pdb
ㄴ[Release]
ㄴassimp-vc143-mt.dll
assimp-vc143-mt.lib
assimp-vc143-mt.exp
assimp-vc143-mt.pdb
다음은 프로젝트 세팅입니다. 먼저 exe 프로젝트의 링커 설정을 진행했습니다. 일반은 Debug, Release 설정이 동일합니다.

입력은 lib간 차이가 있기 때문에, 구성에 따라 다르게 설정해줍니다.


dll 파일은 exe 파일과 동일한 위치에 있어야하는데, [명령줄] 기능을 이용하면 프로젝트 빌드 시 dll 파일을 exe파일이 존재하는 폴더에 복사/붙여넣기할 수 있습니다.

[Debug]
xcopy /Y /D "$(ProjectDir)..\ThirdParty\Assimp\lib\x64\Debug\assimp-vc143-mtd.dll" "$(OutDir)"
[Release]
xcopy /Y /D "$(ProjectDir)..\ThirdParty\Assimp\lib\x64\Release\assimp-vc143-mt.dll" "$(OutDir)"
이후 lib 프로젝트들의 포함 디렉터리에 [..\ThirdParty\Asssimp\inc\]를 넣어주면 됩니다.

[assimp 기능을 활용하여 Mesh 로드하기]
사용할 Mesh는 Maximo에서 구했습니다.
Mixamo
www.mixamo.com
저는 Michelle 캐릭터를 골라서 사용했습니다.

Animation도 필요한게 있으면 받아옵니다.

저는 Walking 외 몇가지 필요한 애니메이션 파일들을 받아왔습니다.
+) Maximo 파일은 FBX에 텍스쳐 파일이 함께 저장되어 있습니다. 텍스쳐를 따로 꺼내오기 위해서는 Blender를 사용해서 추출해야합니다.
Static Mesh와 Dynamic Mesh 로드 과정을 만들었습니다. 설명은 Dynmic Mesh를 로드 기준으로 설명하겠습니다. Static Mesh는 그냥 몇가지 로드만 하고 바로 사용하면 되기 때문입니다. 먼저 파일을 로드할 곳에서 다음과 같이 선언해줍니다.
// DynamicMesh Load
{
Base_Directory Dir;
Dir.MakePath("../Resource/Character/Mesh/Girl.fbx");
Ext_DirectXMesh::CreateDynamicMesh(Dir.GetPath());
}
// Texture도 쓸거면 로드
{
Base_Directory Dir;
Dir.MakePath("../Resource/Character/Texture");
std::vector<std::string> Paths = Dir.GetAllFile({ "png", "tga", "dss" });
for (const std::string& FilePath : Paths)
{
Ext_DirectXTexture::LoadTexture(FilePath.c_str());
}
}
CreateDynamicMesh() 함수를 호출하면 다음의 함수가 호출됩니다.
+) 호출 전에 InputLayout을 추가로 정의해줬습니다.
Ext_DirectXVertexData::GetInputLayoutData().AddInputLayoutDesc("POSITION", DXGI_FORMAT_R32G32B32A32_FLOAT);
Ext_DirectXVertexData::GetInputLayoutData().AddInputLayoutDesc("COLOR", DXGI_FORMAT_R32G32B32A32_FLOAT);
Ext_DirectXVertexData::GetInputLayoutData().AddInputLayoutDesc("TEXCOORD", DXGI_FORMAT_R32G32B32A32_FLOAT);
Ext_DirectXVertexData::GetInputLayoutData().AddInputLayoutDesc("NORMAL", DXGI_FORMAT_R32G32B32A32_FLOAT);
Ext_DirectXVertexData::GetInputLayoutData().AddInputLayoutDesc("TANGENT", DXGI_FORMAT_R32G32B32A32_FLOAT); // Normal Mapping용
Ext_DirectXVertexData::GetInputLayoutData().AddInputLayoutDesc("BINORMAL", DXGI_FORMAT_R32G32B32A32_FLOAT); // Normal Mapping용
Ext_DirectXVertexData::GetInputLayoutData().AddInputLayoutDesc("BONEID", DXGI_FORMAT_R32G32B32A32_SINT); // FBX Animation용
Ext_DirectXVertexData::GetInputLayoutData().AddInputLayoutDesc("WEIGHT", DXGI_FORMAT_R32G32B32A32_FLOAT); // FBX Animation용
// assimp 라이브러리를 활용하여 DynamicMesh 로드하기
std::shared_ptr<Ext_DirectXMesh>Ext_DirectXMesh::CreateDynamicMesh(std::string_view _FilePath)
{
////////////////////////////////////// Bone 정보 추출하기
std::shared_ptr<Ext_SkeltalMesh> NewSkeleton = std::make_shared<Ext_SkeltalMesh>();
const aiScene* AIScene = NewSkeleton->MeshImporter.ReadFile(_FilePath.data(), aiProcess_Triangulate | aiProcess_JoinIdenticalVertices | aiProcess_CalcTangentSpace | aiProcess_GenSmoothNormals | aiProcess_LimitBoneWeights);
if (nullptr == AIScene || AIScene->mFlags & AI_SCENE_FLAGS_INCOMPLETE || nullptr == AIScene->mRootNode)
{
MessageBoxA(nullptr, NewSkeleton->MeshImporter.GetErrorString(), "Dynamic Mesh Import Error", MB_OK);
return nullptr;
}
////////////////////////////////////// 메시로드 시, 파일 안에는 하나의 메시만 존재한다고 가정
const aiMesh* AIMesh = AIScene->mMeshes[0];
std::vector<Ext_DirectXVertexData> Vertices;
std::vector<UINT> Indices;
Vertices.reserve(AIMesh->mNumVertices);
Indices.reserve(AIMesh->mNumFaces * 3);
////////////////////////////////////// 정점 정보 채우기: POSITION, COLOR, TEXCOORD, NORMAL
for (unsigned int i = 0; i < AIMesh->mNumVertices; ++i)
{
Ext_DirectXVertexData VertexData;
VertexData.POSITION = float4(AIMesh->mVertices[i].x, AIMesh->mVertices[i].y, AIMesh->mVertices[i].z);
VertexData.NORMAL = AIMesh->HasNormals() ? float4(AIMesh->mNormals[i].x, AIMesh->mNormals[i].y, AIMesh->mNormals[i].z) : float4::ZERO;
VertexData.TANGENT = AIMesh->HasTangentsAndBitangents() ? float4(AIMesh->mTangents[i].x, AIMesh->mTangents[i].y, AIMesh->mTangents[i].z) : float4::ZERO;
VertexData.BINORMAL = AIMesh->HasTangentsAndBitangents() ? float4(AIMesh->mBitangents[i].x, AIMesh->mBitangents[i].y, AIMesh->mBitangents[i].z) : float4::ZERO;
if (AIMesh->HasTextureCoords(0))
{
//VertexData.TEXCOORD = float4(AIMesh->mTextureCoords[0][i].x, AIMesh->mTextureCoords[0][i].y);
VertexData.TEXCOORD = float4(AIMesh->mTextureCoords[0][i].x, 1.0f - AIMesh->mTextureCoords[0][i].y); // CCW
}
else
{
VertexData.TEXCOORD = float4::ZERO;
}
Vertices.push_back(VertexData);
}
if (!AIMesh->HasBones())
{
MsgAssert("본이 없는 다이나믹메시는 이 방식으로 로드할 수 없습니다.");
return nullptr;
}
// (혹시) 겹치는 이름이 있으면 걸러줌
std::set<std::string> uniqueBoneNames;
for (unsigned int b = 0; b < AIMesh->mNumBones; ++b)
{
uniqueBoneNames.insert(AIMesh->mBones[b]->mName.C_Str());
}
// 수집된 고유 Bone 이름을 순서대로 BoneID 부여 및 OffsetMatrix 저장
for (auto& boneName : uniqueBoneNames)
{
// 첫 번째로 등장하는 aiBone에서 OffsetMatrix 가져오기
aiMatrix4x4 offset;
bool found = false;
for (unsigned int m = 0; m < AIScene->mNumMeshes && !found; ++m)
{
aiMesh* mesh = AIScene->mMeshes[m];
if (!mesh->HasBones()) continue;
for (unsigned int b = 0; b < mesh->mNumBones; ++b)
{
if (boneName == mesh->mBones[b]->mName.C_Str())
{
offset = mesh->mBones[b]->mOffsetMatrix;
found = true;
break;
}
}
}
Ext_SkeltalMesh::BoneInfomation BInf;
BInf.ID = NewSkeleton->BoneCount;
BInf.OffsetMatrix = offset; // 모델공간 → 본공간
NewSkeleton->BoneInfomations[boneName] = BInf;
++NewSkeleton->BoneCount;
}
////////////////////////////////////// 정점 정보 채우기: BONEID, WEIGHT
for (unsigned int b = 0; b < AIMesh->mNumBones; ++b)
{
const aiBone* AibonePtr = AIMesh->mBones[b];
std::string BoneName(AibonePtr->mName.C_Str());
auto It = NewSkeleton->BoneInfomations.find(BoneName);
if (It == NewSkeleton->BoneInfomations.end()) continue;
int BoneID = It->second.ID;
for (unsigned int w = 0; w < AibonePtr->mNumWeights; ++w)
{
unsigned int VertID = AibonePtr->mWeights[w].mVertexId;
float Weight = AibonePtr->mWeights[w].mWeight;
Ext_DirectXVertexData& DestV = Vertices[VertID];
// 네 슬롯(0~3) 중 빈 슬롯에 BoneID/Weight 저장
for (int k = 0; k < 4; ++k)
{
bool IsEmpty = false;
switch (k)
{
case 0: IsEmpty = (DestV.WEIGHT.x == 0.0f); break;
case 1: IsEmpty = (DestV.WEIGHT.y == 0.0f); break;
case 2: IsEmpty = (DestV.WEIGHT.z == 0.0f); break;
case 3: IsEmpty = (DestV.WEIGHT.w == 0.0f); break;
}
if (IsEmpty)
{
switch (k)
{
case 0:
DestV.BONEID.x = static_cast<uint32_t>(BoneID);
DestV.WEIGHT.x = Weight;
break;
case 1:
DestV.BONEID.y = static_cast<uint32_t>(BoneID);
DestV.WEIGHT.y = Weight;
break;
case 2:
DestV.BONEID.z = static_cast<uint32_t>(BoneID);
DestV.WEIGHT.z = Weight;
break;
case 3:
DestV.BONEID.w = static_cast<uint32_t>(BoneID);
DestV.WEIGHT.w = Weight;
break;
}
break;
}
}
}
}
////////////////////////////////////// 인덱스 정보 채우기
for (unsigned int i = 0; i < AIMesh->mNumFaces; ++i)
{
const aiFace& Face = AIMesh->mFaces[i];
if (Face.mNumIndices == 3) // 삼각형만 처리
{
// CW → CCW로 전환 (Face.mIndices[1], [2] 순서 변경)
Indices.push_back(Face.mIndices[0]);
Indices.push_back(Face.mIndices[2]);
Indices.push_back(Face.mIndices[1]);
}
else
{
// 삼각형이 아닐 경우 그대로 넣거나 무시
for (unsigned int j = 0; j < Face.mNumIndices; ++j)
{
Indices.push_back(Face.mIndices[j]);
}
}
}
std::string FileName = Base_Directory::GetFileName(_FilePath);
std::string UpperName = Base_String::ToUpper(FileName);
Ext_DirectXVertexBuffer::CreateVertexBuffer(FileName, Vertices);
Ext_DirectXIndexBuffer::CreateIndexBuffer(FileName, Indices);
NewSkeleton->MeshScene = AIScene;
SkeletalMeshs[UpperName] = NewSkeleton;
return CreateMesh(FileName.c_str());
}
assimp 라이브러리는 로드하는 방식이나 사용 방식을 세세하게 알려주기 때문에 그대로 가져와서 자신의 작업 환경에 맞게 조금 변경해주면 됩니다.
해당 프레임워크에서는 Create할 때 VertexBuffer와 IndexBuffer를 생성하고 그걸 Mesh 클래스가 들고있도록 설계해뒀기 때문에, 로드하는 과정에서 파일에 저장된 값을 불러들인 후 마지막에 CreateMesh()를 실시하여 Mesh를 리소스 매니저에 저장했습니다.
필요로 하는 파일 정보를 추출하기 위해서는 최초에 Assimp::Importer에 플래그들을 세워줘야 하는데, 저는 다음의 플래그들을 사용했습니다.
aiProcess_Triangulate // 메시를 삼각형 폴리곤으로 변환
aiProcess_JoinIdenticalVertices // 중복된 정점 데이터 병합
aiProcess_CalcTangentSpace // Tangent, Bitangent 계산
aiProcess_GenSmoothNormals // SmoothNormal 자동 생성(모델에 노말 없으면 노말 생성)
aiProcess_LimitBoneWeights // 정점당 최대 N개의 Bone Influence만 남김, 가중치 높은 순
함수 내부에서는 크게 다음의 작업들을 진행합니다.
1. Vertex Buffer, Index Buffer를 위한 컨테이너 생성하기
2. SkeletalMesh 값 저장을 위한 구조체를 할당하기
3. POSITION, NORMAL, TANGENT, BINORMAL, TEXCOORD, WEIGHT, BONEID 값을 Vetext Buffer 컨테이너에 넣기
4. SkeletalMesh 구조체에 Bone 갯수, OffsetMatrix, Bone들의 정보, 해당 Mesh Importer 정보, aiScene 정보 저장
5. 정점 그리는 순서 Index Buffer 컨테이너에 넣기
6. Vertex Buffer, Index Buffer 생성
7. Mesh 생성
이러면 Mesh 생성은 끝입니다. 참고로 SkeletalMesh는 Mesh 클래스 위에 하나 추가해줬습니다.
// DynamicMesh의 Bone 정보
class Ext_SkeltalMesh
{
friend class Ext_DirectXMesh;
public:
struct BoneInfomation
{
int ID; // 0부터 시작하는 본 인덱스
aiMatrix4x4 OffsetMatrix; // aiBone->mOffsetMatrix: “모델 스페이스 → 본 로컬 스페이스”
};
// Getter
int GetBoneCount() { return BoneCount; }
const aiScene* GetMeshScene() { return MeshScene; }
std::unordered_map<std::string, BoneInfomation>& GetBoneInfomations() { return BoneInfomations; }
private:
std::unordered_map<std::string, BoneInfomation> BoneInfomations;
int BoneCount = 0;
Assimp::Importer MeshImporter; // 이거 없으면 밑에 깨짐(릴리즈됨)
const aiScene* MeshScene = nullptr; // OwnerAIScene
};
생성된 Skeletal Mesh 정보들은 전역 컨테이너에 저장됩니다. 사실 여기까지만 하고 바로 로드하면 기존에 다른 Mesh들을 로드하는 것과 같이 T-Pose를 취하는 캐릭터를 하나 화면에 바로 띄워볼 수 있습니다.

[애니메이션 로드하기]
DynamicMesh의 경우에는 Animation을 진행하려고 로드하는 것이기 때문에, Ext_DynamicMeshComponent 클래스를 새로 만들어줬습니다. 해당 클래스는 Ext_MeshComponent를 상속받는 클래스입니다. 크게 다른건 없고 Ext_Animator를 하나 내부에 들고있는 클래스입니다.
#pragma once
#include "Ext_MeshComponent.h"
class Ext_DynamicMeshComponent : public Ext_MeshComponent
{
public:
Ext_DynamicMeshComponent() {}
~Ext_DynamicMeshComponent() {}
Ext_DynamicMeshComponent(const Ext_DynamicMeshComponent&) = delete;
Ext_DynamicMeshComponent(Ext_DynamicMeshComponent&&) noexcept = delete;
Ext_DynamicMeshComponent& operator=(const Ext_DynamicMeshComponent&) = delete;
Ext_DynamicMeshComponent& operator=(Ext_DynamicMeshComponent&&) noexcept = delete;
std::shared_ptr<class Ext_MeshComponentUnit> CreateMeshComponentUnit(std::string_view _Mesh, MaterialType _SettingValue) override; // 메시 컴포넌트에 필요한 유닛 생성 및 저장
void CreateAnimation(std::string_view _FilePath);
void SetAnimation(std::string_view _AnimName, bool _IsLoop = false);
bool IsAnimationEnd();
std::shared_ptr<class Ext_Animator> GetAnimator() { return Animator; };
protected:
void Start() override;
void Rendering(float _DeltaTime, const float4 _CameraWorldPosition, const float4x4& _ViewMatrix, const float4x4& _ProjectionMatrix) override;
private:
float AccumulatedTime = 0.0f; // 애니메이션 재생 시간을 누적할 변수
std::string SkinnedCBName = "CB_SkinnedMatrix";
std::shared_ptr<class Ext_Animator> Animator = nullptr; //
};
이제 사용을 위해 Unit의 Initialize를 실시합니다. 기존과 동일하지만, BufferSetting일 하나 추가로 해줘야합니다.
Unit->GetBufferSetter().SetConstantBufferLink(SkinnedCBName, &Animator->GetCBMat(), sizeof(CB_SkinnedMatrix));
바로 Animator에 있는 Bone matrix입니다. 나중에 Vertex Shader에서 이 행렬 정보를 받아 스키닝을 진행하게 됩니다.
// GPU에 넘길 상수 버퍼 구조체
constexpr unsigned int MAX_BONES = 100;
struct CB_SkinnedMatrix
{
float4x4 Bones[MAX_BONES];
};
여기까지 진행했으면, Animation 설정을 진행해줘야합니다. 다음과 같이 실행해줍니다.
Base_Directory Dir;
Dir.MakePath("../Resource/Character/Animation");
std::vector<std::string> Pathse = Dir.GetAllFile({ "fbx" });
for (const std::string& FilePath : Pathse)
{
BodyMesh->CreateAnimation(FilePath);
}
이러면 Animator의 LoadAnimation() 함수가 실행됩니다.
// 애니메이션 생성
bool Ext_Animator::LoadAnimation(std::string_view _FilePath)
{
std::shared_ptr<AnimationData> NewAnimData = std::make_shared<AnimationData>();
// 1) 애니메이션 FBX 읽기 → DirectX(LH) 좌표계로 변환
const aiScene* NewAnimScene = NewAnimData->AnimImporter.ReadFile(_FilePath.data(), aiProcess_Triangulate | aiProcess_GenSmoothNormals | aiProcess_CalcTangentSpace | aiProcess_JoinIdenticalVertices | aiProcess_LimitBoneWeights);
if (!NewAnimScene || (NewAnimScene->mFlags & AI_SCENE_FLAGS_INCOMPLETE) /*|| !AnimSceneAIAnimScenemRootNode*/)
{
MsgAssert("FBX 애니메이션 로드 실패");
return false;
}
// 2) 애니메이션 개수 확인
if (NewAnimScene->mNumAnimations == 0)
{
MsgAssert("애니메이션 채널이 들어 있지 않음");
return false;
}
// 3) 기본 애니메이션 채널 매핑
std::vector<std::string> TranslationNodes; // 루트모션 끄려고 찾는것
CurrentAnimation = NewAnimScene->mAnimations[0];
BoneNameToAnimChannel.clear();
for (unsigned int c = 0; c < CurrentAnimation->mNumChannels; ++c)
{
const aiNodeAnim* Channel = CurrentAnimation->mChannels[c];
BoneNameToAnimChannel[Channel->mNodeName.C_Str()] = Channel;
if (Channel->mNumPositionKeys > 0) // 위치 키가 하나라도 있으면 Translation이 움직이는것
{
TranslationNodes.push_back(Channel->mNodeName.C_Str());
}
}
NewAnimData->AIAnimScene = NewAnimScene;
std::string NewName = Base_Directory::GetFileName(_FilePath);
std::string UpperName = Base_String::ToUpper(NewName);
if (AnimationDatas.find(UpperName) != AnimationDatas.end())
{
MsgAssert("이미 동일한 이름의 애니메이션이 등록되어 있습니다. " + UpperName);
return false;
}
const aiNode* RootNode = SkeletalMesh->GetMeshScene()->mRootNode; // 씬 루트 노드 가져오기
RootMotionBoneName = FindRootMotionNode(RootNode, TranslationNodes); // 최상위 Translation 채널(부모가 없는 채널)을 찾아서 저장
AnimationDatas[UpperName] = NewAnimData; // 다 끝났으면 저장
return true;
}
Mesh 로드와 마찬가지로 Importer를 통해 로드를 진행하는데, 플래그를 설정해줄 수 있습니다. 플래그는 Mesh 로드때와 동일합니다.
여기서도 로드 방식은 알려진 것에서 저에게 알맞게 수정했습니다. 한 가지 바뀐 점은 RootMotion을 끄기 위해 mNumPositionKeys가 있는 Animation의 RootNodeName을 따로 이름으로 찾아둔 것입니다(RootMotionBoneName(string)에 저장). 위 함수 실행이 정상적으로 진행됐다면 [const aiAnimation* CurrentAnimation] 멤버 변수에 현재 애니메이션으로 저장됩니다(이게 끝임).
애니메이션을 Setting할때는 SetAnimation() 함수를 호출하는데, 이것도 그냥 저장된 aiAnimation 중에 이름값이 동일한 애니메이션을 찾아서 지정해주는 방식입니다(자세한건 너무 길어지니 깃허브 코드 확인으로,,,).
[애니메이션 행렬 계산하기]
Mesh와 Animation이 모두 로드됐으면, Animation을 매 프레임마다 실행해주면 됩니다. 이를 위해 Ext_DynamicMeshComponent 클래스의 Rendering() 함수 내부에서 다음의 함수를 호출해줍니다.
void Ext_DynamicMeshComponent::Rendering(float _DeltaTime, const float4 _CameraWorldPosition, const float4x4& _ViewMatrix, const float4x4& _ProjectionMatrix)
{
// 기본 렌더링 (Transform / Material 셋업 후)
__super::Rendering(_DeltaTime, _CameraWorldPosition, _ViewMatrix, _ProjectionMatrix);
Animator->UpdateAnimation(_DeltaTime);
}
UpdateAnimation() 함수로 들어가 봅시다.
// 선택된 애니메이션 재생
void Ext_Animator::UpdateAnimation(float _DeltaTime)
{
if (!CurrentAnimation) // aiScene이 없거나
{
return;
}
// 1) FinalBoneMatrices 초기화
for (size_t i = 0; i < SkeletalMesh->GetBoneCount(); ++i)
{
FinalBoneMatrices[i] = aiMatrix4x4();
}
AccumulatedTime += _DeltaTime;
// 애니메이션 끝났는지 검사
bIsAnimationEnd = false;
bool HasEnded = (AccumulatedTime >= AnimationLengthSec);
if (HasEnded)
{
bIsAnimationEnd = true;
if (bIsLoop)
{
AccumulatedTime = fmod(AccumulatedTime, (float)AnimationLengthSec); // 루핑 재생이라면, 누적 시간을 “mod”해 주거나 리셋
}
else
{
AccumulatedTime = (float)AnimationLengthSec; // 비루핑(딱 한 번 재생)이라면, AccumulatedTime을 최대 길이로 고정
}
}
const aiNode* RootNode = SkeletalMesh->GetMeshScene()->mRootNode;
aiMatrix4x4 Identity; // 기본 생성자 → 항등행렬
ReadNodeHierarchy(AccumulatedTime, RootNode, Identity);
RenderSkinnedMesh(); // 다 돌았으면 CB에 값 저장
}
해당 함수의 최종 목적은 Unit을 Initialize하는 과정에서 BufferSetting을 진행해줬던 CBMat에 애니메이션 연산 결과를 저장하여 GPU에서 사용할 수 있도록 하는 것입니다. 애니메이션 행렬 연산은 ReadNodeHierarchy() 함수가 재귀적으로 동작하면서 수행됩니다.
// 재귀적으로 노드 트리를 순회하여 FinalBoneMatrices에 값 적용
aiMatrix4x4 Ext_Animator::ReadNodeHierarchy(float _AccumulatedTime, const aiNode* _CurNode, const aiMatrix4x4& _ParentTransform)
{
// 현재 노드 이름
std::string CurNodeName = _CurNode->mName.C_Str();
aiMatrix4x4 Mat;
// 애니메이션 채널 보간 여부
auto AnimIter = BoneNameToAnimChannel.find(CurNodeName);
if (AnimIter != BoneNameToAnimChannel.end())
{
const aiNodeAnim* Channel = AnimIter->second;
// 초 → 틱 변환
float Ticks = TimeInTicks(_AccumulatedTime);
float Duration = static_cast<float>(CurrentAnimation->mDuration);
float AnimTime = fmod(Ticks, Duration);
// 보간 후 TRS
aiVector3D InterpPosition;
aiQuaternion InterpRotation;
aiVector3D InterpScale;
CalcInterpolatedPosition(InterpPosition, AnimTime, Channel);
CalcInterpolatedRotation(InterpRotation, AnimTime, Channel);
CalcInterpolatedScaling(InterpScale, AnimTime, Channel);
if ("" != RootMotionBoneName && CurNodeName == RootMotionBoneName)
{
InterpPosition.x = 0.0f;
InterpPosition.y = 0.0f;
InterpPosition.z = 0.0f;
}
// 일단 사용(루트모션 제거용)
//InterpPosition.z = 0.0f;
aiMatrix4x4 TranslationMat;
aiMatrix4x4 RotationMat;
aiMatrix4x4 ScaleMat;
aiMatrix4x4::Translation(InterpPosition, TranslationMat);
RotationMat = aiMatrix4x4(InterpRotation.GetMatrix());
aiMatrix4x4::Scaling(InterpScale, ScaleMat);
Mat = TranslationMat * RotationMat * ScaleMat;
}
// 부모 변환과 곱해 글로벌 변환 계산
aiMatrix4x4 GlobalTransform = _ParentTransform * Mat;
// 이 노드가 본(BoneNameToInfo에 존재)이라면 최종 스킨 행렬 계산
auto BoneIter = SkeletalMesh->GetBoneInfomations().find(CurNodeName);
if (BoneIter != SkeletalMesh->GetBoneInfomations().end())
{
int BoneID = BoneIter->second.ID;
const aiMatrix4x4& OffsetMat = BoneIter->second.OffsetMatrix;
aiMatrix4x4 FinalBoneTransform = GlobalTransform * OffsetMat;
FinalBoneMatrices[BoneID] = FinalBoneTransform;
}
// (6) 자식 노드 순회
for (unsigned int i = 0; i < _CurNode->mNumChildren; ++i)
{
ReadNodeHierarchy(_AccumulatedTime, _CurNode->mChildren[i], GlobalTransform);
}
return GlobalTransform;
}
해당 부분도 assimp 사용 방식에서 크게 다른 점은 없습니다. 중요한 점은 TranslationMat, RotationMat, ScaleMat를 구하고 이들을 곱해서 SRT 행렬로 만든다음, 이걸 부모 행렬과 곱하여 GlobalTransform을 구하고, 맨 처음에 Mesh 로드 과정에서 구한 OffsetMat에 적용시키는 것입니다. 이러면 애니메이션의 흐름에 따라 Bone들이 월드 좌표 기준으로 어디에 위치하게 되는지 대략적으로 구해집니다. 이 정보들을 FinalBoneMatrices라는 std::vector<aiMatrix4x4> 컨테이너에 저장해줍니다.
모든 Bone들의 행렬 연산이 끝났다면, 이제 RenderSkinnedMesh() 함수로 이동하여 CBMat에 정보를 넘겨줍니다.
void Ext_Animator::RenderSkinnedMesh()
{
// FinalBoneMatrices[i]를 그대로 XMMATRIX 생성자에 넘겨 주면 됩니다
for (size_t i = 0; i < FinalBoneMatrices.size() && i < MAX_BONES; ++i)
{
const aiMatrix4x4& m = FinalBoneMatrices[i];
// “행 우선(row-major)”대로 XMMATRIX 생성
DirectX::XMMATRIX xm = DirectX::XMMATRIX(
m.a1, m.a2, m.a3, m.a4,
m.b1, m.b2, m.b3, m.b4,
m.c1, m.c2, m.c3, m.c4,
m.d1, m.d2, m.d3, m.d4
);
CBMat.Bones[i] = xm;
}
// 나머지 본은 단위행렬로 채우기
for (size_t i = FinalBoneMatrices.size(); i < MAX_BONES; ++i)
{
CBMat.Bones[i] = DirectX::XMMatrixIdentity();
}
}
이렇게 해서 aiMatrix4x4를 XMMATRIX로 암시적 변환하여 저장해줍니다. 최종적으로 이 값은 상수버퍼로 Vertex Shader에 전달됩니다.
[애니메이션 스키닝 계산하기]
#include "Transform.fx"
#define MAX_BONES 100
cbuffer CB_SkinnedMatrix : register(b1)
{
float4x4 Bones[MAX_BONES];
};
struct VSInput
{
float4 Position : POSITION;
float4 TexCoord : TEXCOORD;
float4 Normal : NORMAL;
uint4 BoneID : BONEID;
float4 Weight : WEIGHT;
float4 Tangent : TANGENT; // 로컬 접선
float4 Binormal : BINORMAL; // 로컬 이접선
};
struct VSOutput
{
float4 Position : SV_POSITION;
float2 TexCoord : TEXCOORD;
float3 WorldPosition : POSITION;
float3 WorldNormal : NORMAL;
float3 WorldTangent : TANGENT;
float3 WorldBinormal : BINORMAL;
};
VSOutput DynamicPBR_VS(VSInput _Input)
{
VSOutput Output = (VSOutput) 0;
// 1. 스키닝 매트릭스 계산
float4x4 SkinMatrix = float4x4(
0, 0, 0, 0,
0, 0, 0, 0,
0, 0, 0, 0,
0, 0, 0, 0
);
[unroll]
for (int i = 0; i < 4; ++i)
{
uint BoneIndex = _Input.BoneID[i];
float Weight = _Input.Weight[i];
if (Weight > 0.0f && BoneIndex < MAX_BONES)
{
SkinMatrix += Bones[BoneIndex] * Weight;
}
}
// SkinNormal 계산
float4 SkinPosition;
SkinPosition.x = dot(SkinMatrix[0], _Input.Position); // row0 · [x y z 1]
SkinPosition.y = dot(SkinMatrix[1], _Input.Position); // row1 · [x y z 1]
SkinPosition.z = dot(SkinMatrix[2], _Input.Position); // row2 · [x y z 1]
SkinPosition.w = dot(SkinMatrix[3], _Input.Position); // row3 · [x y z 1]
float3 SkinNormal;
{
float3 Row0 = float3(SkinMatrix[0][0], SkinMatrix[0][1], SkinMatrix[0][2]);
float3 Row1 = float3(SkinMatrix[1][0], SkinMatrix[1][1], SkinMatrix[1][2]);
float3 Row2 = float3(SkinMatrix[2][0], SkinMatrix[2][1], SkinMatrix[2][2]);
float3 LocalN = _Input.Normal.xyz;
SkinNormal.x = dot(Row0, LocalN);
SkinNormal.y = dot(Row1, LocalN);
SkinNormal.z = dot(Row2, LocalN);
SkinNormal = normalize(SkinNormal);
}
// SkinTangent 계산
float3 SkinTangent;
{
float3 Row0 = float3(SkinMatrix[0][0], SkinMatrix[0][1], SkinMatrix[0][2]);
float3 Row1 = float3(SkinMatrix[1][0], SkinMatrix[1][1], SkinMatrix[1][2]);
float3 Row2 = float3(SkinMatrix[2][0], SkinMatrix[2][1], SkinMatrix[2][2]);
float3 LocalT = _Input.Tangent.xyz;
SkinTangent.x = dot(Row0, LocalT);
SkinTangent.y = dot(Row1, LocalT);
SkinTangent.z = dot(Row2, LocalT);
SkinTangent = normalize(SkinTangent);
}
// SkinBinormal 계산
float3 SkinBinormal;
{
float3 Row0 = float3(SkinMatrix[0][0], SkinMatrix[0][1], SkinMatrix[0][2]);
float3 Row1 = float3(SkinMatrix[1][0], SkinMatrix[1][1], SkinMatrix[1][2]);
float3 Row2 = float3(SkinMatrix[2][0], SkinMatrix[2][1], SkinMatrix[2][2]);
float3 LocalB = _Input.Binormal.xyz;
SkinBinormal.x = dot(Row0, LocalB);
SkinBinormal.y = dot(Row1, LocalB);
SkinBinormal.z = dot(Row2, LocalB);
SkinBinormal = normalize(SkinBinormal);
}
// Position 설정
float4 WorldPos = mul(SkinPosition, WorldMatrix);
float4 ViewPos = mul(WorldPos, ViewMatrix);
Output.Position = mul(ViewPos, ProjectionMatrix);
// UV 좌표 설정
Output.TexCoord = _Input.TexCoord.xy;
// 월드 공간 기준으로 조명 계산을 진행하기 위해 WorldMatrix만 처리한 Position, Normal을 생성하여 Pixel Shader에 넘겨줌
Output.WorldPosition = WorldPos.xyz;
Output.WorldNormal = mul(SkinNormal, (float3x3) WorldMatrix);
Output.WorldTangent = mul(SkinTangent, (float3x3) WorldMatrix);
Output.WorldBinormal = mul(SkinBinormal, (float3x3) WorldMatrix);
return Output;
}
CPU에서 각 Bone의 최종 월드/로컬 행렬들을 정해줬는데, 추가로 연산을 진행하는게 의아하실 수 있습니다.
// 1. 스키닝 매트릭스 계산
float4x4 SkinMatrix = float4x4(
0, 0, 0, 0,
0, 0, 0, 0,
0, 0, 0, 0,
0, 0, 0, 0
);
[unroll]
for (int i = 0; i < 4; ++i)
{
uint BoneIndex = _Input.BoneID[i];
float Weight = _Input.Weight[i];
if (Weight > 0.0f && BoneIndex < MAX_BONES)
{
SkinMatrix += Bones[BoneIndex] * Weight;
}
}
float4 SkinPosition;
SkinPosition.x = dot(SkinMatrix[0], _Input.Position); // row0 · [x y z 1]
SkinPosition.y = dot(SkinMatrix[1], _Input.Position); // row1 · [x y z 1]
SkinPosition.z = dot(SkinMatrix[2], _Input.Position); // row2 · [x y z 1]
SkinPosition.w = dot(SkinMatrix[3], _Input.Position); // row3 · [x y z 1]
해당 부분은 정점이 여러 개의 Bone 영향을 받는 스키닝(Skinning; 가중 평균 변환) 연산을 수행하기 위함입니다. 해당 연산을 수행해야 메시가 실제로 움직입니다(메시가 이동되는 것).
1. 초기화
: SkinMatrix를 모두 0으로 초기화합니다.
2. 최대 4개의 Bone에 대해 반복
: 보통 하나의 정점은 최대 4개의 Bone에 영향을 받는다고 제한합니다. GPU 구조상 효율 문제가 있기 때문입니다.
for (int i = 0; i < 4; ++i)
3. Bone ID와 Weight 추출
: 정점이 영향을 받는 Bone ID와 그 Bone의 Weight를 가져옵니다.
uint BoneIndex = _Input.BoneID[i];
float Weight = _Input.Weight[i];
4. Weight가 0 이상이고, 유효한 Bone ID일 경우
: 해당 Bone 행렬 Bones[BoneIndex]를 가중치만큼 곱해서 누적합니다.
SkinMatrix += Bones[BoneIndex] * Weight;
Bones[BoneIndex]는 앞서 CPU에서 계산해준 값입니다. 여기서 Weight는 Bone이 해당 정점에 미치는 영향인데, 0.0 ~ 1.0 사이 값을 갖습니다. 이걸 가중 합산하면 정점의 최종 변환 결과가 나옵니다.
5. 결과 적용
: 이제 이 값을 정점에 적용해줍니다. Position과 Noraml에 SkinMatrix를 적용해주면 됩니다.
// 스키닝된 모델 공간의 좌표/노말 계산
float4 SkinPosition;
SkinPosition.x = dot(SkinMatrix[0], _Input.Position); // row0 · [x y z 1]
SkinPosition.y = dot(SkinMatrix[1], _Input.Position); // row1 · [x y z 1]
SkinPosition.z = dot(SkinMatrix[2], _Input.Position); // row2 · [x y z 1]
SkinPosition.w = dot(SkinMatrix[3], _Input.Position); // row3 · [x y z 1]
float3 SkinNormal;
float3 row0 = float3(SkinMatrix[0][0], SkinMatrix[0][1], SkinMatrix[0][2]);
float3 row1 = float3(SkinMatrix[1][0], SkinMatrix[1][1], SkinMatrix[1][2]);
float3 row2 = float3(SkinMatrix[2][0], SkinMatrix[2][1], SkinMatrix[2][2]);
SkinNormal.x = dot(row0, _Input.Normal.xyz);
SkinNormal.y = dot(row1, _Input.Normal.xyz);
SkinNormal.z = dot(row2, _Input.Normal.xyz);
SkinNormal = normalize(SkinNormal);
여기까지 해주면 정상적으로 애니메이션을 수행할 수 있게 됩니다.
'DirectX11 > 프레임워크 제작' 카테고리의 다른 글
| [DirectX11] Lighting 추가하기 (0) | 2025.06.06 |
|---|---|
| [DirectX11] DirectX11 프레임워크를 위한 기능 추가 - 3 + 간단한 Lighting 해보기 (0) | 2025.06.04 |
| [DirectX11] BlendState 생성 (0) | 2025.05.28 |
| [DirectX11] Texcoord(UV)와 Sampler (0) | 2025.05.28 |
| [DirectX11] DirectXTex를 통한 Texture 활용 (0) | 2025.05.28 |