Make Unreal REAL.
article thumbnail
이득우의 게임 수학

 

트랜스폼의 계층 구조 시스템이 완성됐으니, 이를 기반으로 계층 구조를 이루는 캐릭터를 직접 제작해본다.

 

실습해 볼 캐릭터의 본 구조는 다음과 같다.

 

 

책의 예제인 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
profile

Make Unreal REAL.

@diesuki4

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!

검색 태그