이득우의 게임 수학
트랜스폼의 계층 구조 시스템이 완성됐으니, 이를 기반으로 계층 구조를 이루는 캐릭터를 직접 제작해본다.
실습해 볼 캐릭터의 본 구조는 다음과 같다.
책의 예제인 CK소프트렌더러에서 캐릭터 메시를 생성하고 계층 구조로 본을 생성한 후, 리깅을 진행해 스킨드 메시를 완성하는 부분은 다음과 같다.
- 하나의 정점에 하나의 본만 연결하는 단순한 구조로 리깅한다.
// 책의 예제인 CK소프트렌더러에서 캐릭터 메시를 선언하고 데이터를 설정하는 부분
bool GameEngine::LoadResources()
{
// 각 부위의 직육면체 크기를 지정
constexpr Vector3 headSize(0.5f, 0.5f, 0.5f);
constexpr Vector3 bodySize(0.5f, 0.75f, 0.25f);
constexpr Vector3 armLegSize(0.25f, 0.75f, 0.25f);
// 캐릭터를 총 6개의 부위로 구분한다.
constexpr BYTE totalCharacterParts = 6;
Mesh& characterMesh = CreateMesh(GameEngine::CharacterMesh);
auto& v = characterMesh.GetVertices();
auto& i = characterMesh.GetIndices();
auto& uv = characterMesh.GetUVs();
// 각 부위별 크기를 지정
static constexpr array<Vector3, totalCharacterParts> cubeMeshSize = {
headSize, bodySize, armLegSize, armLegSize, armLegSize, armLegSize
};
// 각 부위별 오프셋을 지정
static constexpr array<Vector3, totalCharacterParts> cubeMeshOffset = {
Vector3(0.f, 3.5f, 0.f), Vector3(0.f, 2.25f, 0.f), Vector3(-0.75f, 2.25f, 0.f), Vector3(0.75f, 2.25f, 0.f), Vector3(-0.25f, 0.75f, 0.f), Vector3(0.25f, 0.75f, 0.f)
};
// 각 부위별 정점 버퍼와 인덱스 버퍼 생성
for (size_t part = 0; part < totalCharacterParts; ++part)
{
transform(cubeMeshPositions.begin(), cubeMeshPositions.end(), back_inserter(v), [&](auto& p) { return p * cubeMeshSize[part] + cubeMeshOffset[part]; });
transform(cubeMeshIndice.begin(), cubeMeshIndice.end(), back_inserter(i), [&](auto& p) { return p + 24 * part; });
}
...
// 캐릭터 스킨드 메시 설정
characterMesh.SetMeshType(MeshType::Skinned);
auto& cb = characterMesh.GetConnectedBones();
auto& w = characterMesh.GetWeights();
auto& bones = characterMesh.GetBones();
// 본 생성
bones = {
{ GameEngine::RootBone, Bone(GameEngine::RootBone, Transform()) },
{ GameEngine::PelvisBone, Bone(GameEngine::PelvisBone, Transform(Vector3(0.f, 1.5f, 0.f))) },
{ GameEngine::SpineBone, Bone(GameEngine::SpineBone, Transform(Vector3(0.f, 2.25f, 0.f))) },
{ GameEngine::LeftArmBone, Bone(GameEngine::LeftArmBone, Transform(Vector3(-0.75f, 3.f, 0.f))) },
{ GameEngine::RightArmBone, Bone(GameEngine::RightArmBone, Transform(Vector3(0.75f, 3.f, 0.f))) },
{ GameEngine::LeftLegBone, Bone(GameEngine::LeftLegBone, Transform(Vector3(0.25f, 1.5f, 0.f))) },
{ GameEngine::RightLegBone, Bone(GameEngine::RightLegBone, Transform(Vector3(-0.25f, 1.5f, 0.f))) },
{ GameEngine::NeckBone, Bone(GameEngine::NeckBone, Transform(Vector3(0.f, 3.f, 0.f))) }
};
// 본의 계층 구조 생성
Bone& root = characterMesh.GetBone(GameEngine::RootBone);
Bone& pelvis = characterMesh.GetBone(GameEngine::PelvisBone); pelvis.SetParent(root);
Bone& spine = characterMesh.GetBone(GameEngine::SpineBone); spine.SetParent(pelvis);
Bone& leftArm = characterMesh.GetBone(GameEngine::LeftArmBone); leftArm.SetParent(spine);
Bone& rightArm = characterMesh.GetBone(GameEngine::RightArmBone); rightArm.SetParent(spine);
Bone& leftLeg = characterMesh.GetBone(GameEngine::LeftLegBone); leftLeg.SetParent(pelvis);
Bone& rightLeg = characterMesh.GetBone(GameEngine::RightLegBone); rightLeg.SetParent(pelvis);
Bone& neck = characterMesh.GetBone(GameEngine::NeckBone); neck.SetParent(spine);
// 메시에 리깅
static array<string, 6> boneOrder = {
GameEngine::NeckBone, GameEngine::SpineBone, GameEngine::LeftArmBone, GameEngine::RightArmBone, GameEngine::LeftLegBone, GameEngine::RightLegBone
};
cb.resize(v.size());
w.resize(v.size());
// 정점과 연결된 본의 수는 1개로 지정
fill(cb.begin(), cb.end(), 1);
// 각 부위별 정점의 가중치 설정
for (size_t part = 0; part < 6; ++part)
{
Weight weight;
weight.Bones = { boneOrder[part] };
weight.Values = { 1.f };
auto startIt = w.begin() + part * 24;
fill(startIt, startIt + 24, weight);
}
characterMesh.CalculateBounds();
// 화살표 메시 (본의 표시 용도)
Mesh& arrow = CreateMesh(GameEngine::ArrowMesh);
...
}
스킨드 메시를 구성하는 데이터가 준비됐다면, 이를 이용해 움직이는 캐릭터를 직접 구현해본다.
캐릭터 애니메이션을 수행할 씬을 구성하는 부분은 다음과 같다.
// 책의 예제인 CK소프트렌더러에서 예제 씬을 구성하는 부분
const string PlayerGo("Player");
const string CameraTargetGo("CameraTarget");
// 씬 로딩
void SoftRenderer::LoadScene3D()
{
GameEngine& g = Get3DGameEngine();
// 플레이어
constexpr float playerScale = 100.f;
GameObject& goPlayer = g.CreateNewGameObject(PlayerGo);
goPlayer.SetMesh(GameEngine::CharacterMesh);
goPlayer.SetColor(LinearColor::White);
goPlayer.GetTransform().SetWorldScale(Vector3::One * playerScale);
// 캐릭터 본을 표시할 화살표
Mesh& cm = g.GetMesh(goPlayer.GetMeshKey());
for (const auto& b : cm.GetBones())
{
if (!b.second.HasParent())
{
continue;
}
GameObject& goBoneArrow = g.CreateNewGameObject(b.second.GetName());
goBoneArrow.SetMesh(GameEngine::ArrowMesh);
g.GetBoneObjectPtrs().insert({ goBoneArrow.GetName(), &goBoneArrow });
}
// 카메라 릭
GameObject& goCameraTarget = g.CreateNewGameObject(CameraTargetGo);
goCameraTarget.GetTransform().SetWorldPosition(Vector3(0.f, 150.f, 0.f));
goCameraTarget.SetParent(goPlayer);
// 카메라 설정
CameraObject& mainCamera = g.GetMainCamera();
mainCamera.GetTransform().SetWorldPosition(Vector3(-500.f, 800.f, 1000.f));
mainCamera.SetLookAtRotation(goCameraTarget);
}
애니메이션을 수행하기 위한 키프레임 데이터를 생성하고, 이를 이용해 각 본을 회전시키는 부분은 다음과 같다.
- sin 함수를 사용해 양팔과 양다리, 머리가 서로 다른 속도로 회전하도록 한다.
// 책의 예제인 CK소프트렌더러에서 애니메이션 로직을 담당하는 함수
void SoftRenderer::LateUpdate3D(float InDeltaSeconds)
{
// 애니메이션 로직에서 사용하는 모듈 내 주요 레퍼런스
GameEngine& g = Get3DGameEngine();
// 애니메이션 로직의 로컬 변수
static float elapsedTime = 0.f;
static float neckLength = 5.f;
static float armLegLength = 0.7f;
static float neckDegree = 15.f;
static float armLegDegree = 30.f;
elapsedTime += InDeltaSeconds;
// 애니메이션을 위한 커브 생성
float neckCurrent = Math::FMod(elapsedTime, neckLength) * Math::TwoPI / neckLength;
float armLegCurrent = Math::FMod(elapsedTime, armLegLength) * Math::TwoPI / armLegLength;
float neckCurve = sinf(neckCurrent) * neckDegree;
float armLegCurve = sinf(armLegCurrent) * armLegDegree;
// 캐릭터 레퍼런스
GameObject& goPlayer = g.GetGameObject(PlayerGo);
// 캐릭터 메시
Mesh& m = g.GetMesh(goPlayer.GetMeshKey());
// 목의 회전
Bone& neckBone = m.GetBone(GameEngine::NeckBone);
neckBone.GetTransform().SetLocalRotation(Rotator(neckCurve, 0.f, 0.f));
// 팔의 회전
Bone& leftArmBone = m.GetBone(GameEngine::LeftArmBone);
leftArmBone.GetTransform().SetLocalRotation(Rotator(0.f, 0.f, armLegCurve));
Bone& rightArmBone = m.GetBone(GameEngine::RightArmBone);
rightArmBone.GetTransform().SetLocalRotation(Rotator(0.f, 0.f, -armLegCurve));
// 다리의 회전
Bone& leftLegBone = m.GetBone(GameEngine::LeftLegBone);
leftLegBone.GetTransform().SetLocalRotation(Rotator(0.f, 0.f, -armLegCurve));
Bone& rightLegBone = m.GetBone(GameEngine::RightLegBone);
rightLegBone.GetTransform().SetLocalRotation(Rotator(0.f, 0.f, armLegCurve));
}
마지막으로는 바인드포즈에 설정된 트랜스폼을 사용해 애니메이션에 의해 움직인 본의 변화량을 구하고, 이를 반영해 최종 정점의 위치를 계산해준다.
먼저 메시를 설계할 때 사용한 모델링 공간을 기준으로 현재 애니메이션에 의해 움직이고 있는 본의 트랜스폼을 계산한다.
- 이는 최초에 모델링 공간으로 설정된 바인드포즈와 동일한 공간으로 일치시키기 위함이다.
바인드포즈와 본의 트랜스폼이 동일한 공간으로 설정됐다면, 바인드 포즈를 중심으로 본의 로컬 트랜스폼을 얻어낸다.
이렇게 구한 로컬 트랜스폼을 각 정점에 적용한 후 다시 모델링 공간으로 변환하면, 계층 구조를 지원하는 캐릭터 애니메이션의 변환이 완료된다.
// 책의 예제인 CK소프트렌더러에서 스키닝 메시의 정점을 변환하는 부분
void SoftRenderer::DrawMesh3D(const Mesh& InMesh, const Matrix4x4& InMatrix, const LinearColor& InColor)
{
size_t vertexCount = InMesh.GetVertices().size();
size_t indexCount = InMesh.GetIndices().size();
size_t triangleCount = indexCount / 3;
// 렌더러가 사용할 정점 버퍼와 인덱스 버퍼로 변환
vector<Vertex3D> vertices(vertexCount);
vector<size_t> indice(InMesh.GetIndices());
for (size_t vi = 0; vi < vertexCount; ++vi)
{
vertices[vi].Position = Vector4(InMesh.GetVertices()[vi]);
// 위치에 대해 스키닝 연산 수행
if (InMesh.IsSkinnedMesh())
{
// 최종 위치가 저장될 변수
Vector4 totalPosition = Vector4::Zero;
Weight w = InMesh.GetWeights()[vi];
for (size_t wi = 0; wi < InMesh.GetConnectedBones()[vi]; ++wi)
{
string boneName = w.Bones[wi];
if (InMesh.HasBone(boneName))
{
const Bone& b = InMesh.GetBone(boneName);
// 본의 월드 트랜스폼 좌표 (모델링 공간)
const Transform& t = b.GetTransform().GetWorldTransform();
// 해당 본의 BindPose의 월드 트랜스폼 좌표 (모델링 공간)
const Transform& bindPose = b.GetBindPose();
// BindPose 공간을 중심으로 Bone의 로컬 공간을 계산
Transform boneLocal = t.WorldToLocal(bindPose);
// BindPose 공간을 중심으로 정점의 위치를 계산
Vector3 localPosition = bindPose.WorldToLocalVector(vertices[vi].Position.ToVector3());
// BindPose 공간에서의 점의 최종 위치
Vector3 skinnedLocalPosition = boneLocal.GetMatrix() * localPosition;
// 모델링 공간으로 다시 변경
Vector3 skinnedWorldPosition = bindPose.GetMatrix() * skinnedLocalPosition;
// 가중치를 반영한 후 최종 위치에 누적
totalPosition += Vector4(skinnedWorldPosition, true) * w.Values[wi];
}
}
vertices[vi].Position = totalPosition;
}
if (InMesh.HasColor())
{
vertices[vi].Color = InMesh.GetColors()[vi];
}
if (InMesh.HasUV())
{
vertices[vi].UV = InMesh.GetUVs()[vi];
}
}
...
}
책의 최종 결과물은 다음과 같다.
'게임 수학 > 이득우의 게임 수학' 카테고리의 다른 글
완독 및 후기 (0) | 2023.06.12 |
---|---|
트랜스폼 계층 구조 (0) | 2023.06.10 |
스켈레탈 애니메이션 (0) | 2023.06.09 |
사원수의 보간 (0) | 2023.06.08 |
사원수의 변환 (0) | 2023.06.07 |