스켈레탈 메쉬 구현

Shell·2026년 4월 21일

개요

3일간 ShellEngine에 스켈레탈 애니메이션을 붙이는 작업을 시작했다.
유니티로 치면 SkinnedMeshRenderer를 만드는 작업인데, 지금까지는 일반 MeshRenderer만 있었다.
3D 게임에 생동감을 주는 필수 요소여서 초기부터 생각해뒀지만, 여태까지 2D 게임을 만드느냐 미뤄두고 있다가 다시 시작했다.

이번 작업 범위는 애니메이션 재생까지는 아니고, glTF에서 스킨 데이터를 읽어와서 본 트랜스폼을 GPU에 올려 메쉬가 올바르게 변형되는 것까지로 정했다.
그 과정에서 Model 구조 리팩토링, SubMesh 도입, 멀티 메테리얼 지원까지 다양한 작업을 진행했다.

Model 구조 리팩토링: 트리 -> 배열

스켈레탈 메쉬를 다루려면 노드가 어떤 스켈레톤을 쓰는지 알아야 하고, 본 트랜스폼을 참고할 때 인덱스로 빠르게 접근해야 한다.
기존Model::Nodestd::vector<std::unique_ptr<Node>> children;으로 재귀 트리 구조였는데, 이 방식으로는 n번째 노드에 O(n)만큼의 시간이 걸린다.
그래서 인덱스만 알면 O(1)으로 접근 할 수 있게 만들었다.

// Before
struct Node {
    std::string name;
    glm::mat4 modelMatrix;
    Mesh* mesh = nullptr;
    std::vector<std::unique_ptr<Node>> children;
};
std::unique_ptr<Node> rootNode;
// After
struct Node {
    std::string name;
    glm::mat4 modelMatrix{ 1.0f };
    Mesh* mesh = nullptr;
    int skeletonIdx = -1;
    std::vector<int> childrenIdx;
};
std::vector<Node> nodes;
std::vector<Skeleton> skeletons;

자식 목록도 포인터 대신 인덱스 배열로 바꿨다.
덕분에 노드 배열 전체를 그냥 순회하거나 BFS를 돌릴 수 있고, 특정 노드도 O(1)에 접근할 수 있다.

Skeleton과 SkinnedMesh

Skeleton 구조체는 glTF skin의 데이터를 담는다. 각 조인트는 노드 인덱스와 Inverse Bind Matrix(IBM)를 가진다.

struct Skeleton {
    struct Joint {
        int nodeIdx = -1;
        glm::mat4 inverseBindMat{ 1.f };
    };
    std::vector<Joint> joints;
};

SkinnedMesh는 기존 Mesh를 상속해서 정점마다 갖는 본 데이터를 추가했다.

struct BoneVertex {
    glm::ivec4 boneIndices{ 0 };
    glm::vec4 boneWeights{ 0.0f };
};
static constexpr uint32_t MAX_BONES = 128;

그래픽스 API쪽인 Vulkan에서는 VulkanSkinnedVertexBuffer를 새로 만들어서 두 개의 버텍스 버퍼를 관리한다.
binding 0은 기존 Mesh::Vertex(위치, 노말, UV, 탄젠트),
binding 1은 BoneVertex(본 인덱스/가중치)다.

또한 이에 맞춰 스킨 메쉬 전용 버텍스 버퍼를 추가해서 SkinnedMeshBuild() 호출 시 자동으로 VulkanSkinnedVertexBuffer를 생성하도록 했다.

ShellEngine은 언제든지 다른 그래픽스 API로 확장할 수 있게 그래픽스 API 구조는 대부분 추상화 해뒀다.
VertexBufferFactory에서 그래픽스 API에 맞는 버퍼를 생성해준다.

glTF 스킨 로딩

ModelLoader에서 glTF skins를 읽어 Skeleton을 구성하고, MeshLoader에서 JOINTS_0 / WEIGHTS_0 어트리뷰트를 읽어 BoneVertex 배열을 채운다.
노드를 재귀로 파싱하던 부분도 반복문으로 바꿔서 배열에 순서대로 쌓도록 정리했다.

SkinnedMeshRenderer 컴포넌트

MeshRenderer를 상속하는 SkinnedMeshRenderer를 추가했다.
핵심 역할은 매 프레임 본 트랜스폼에서 최종 행렬을 계산해서 GPU UBO에 올리는 것이다.

finalBoneMatrix[i] = bones[i]->localToWorldMatrix * inverseBindMatrices[i]

이 행렬을 버텍스 정점에 곱하게 되면 어떻게 될까?

  1. 버텍스 정점이 메쉬의 로컬 좌표계에서 바인드 포즈 상태의 본의 좌표계로 이동한다.
  2. 본의 좌표계에서 localToWorldMatrix를 곱하여 월드 좌표계로 변환된다.

즉, 버텍스가 본을 따라서 움직이게 되는 것이다!

이제 이걸 BONES_UBO(set=1, binding=n)로 업로드하면 버텍스 셰이더에서 본 행렬을 읽어 스키닝을 수행한다.
binding 번호는 이 뒤에 만든 셰이더 파서가 셰이더 코드를 읽고 알아서 할당해준다.

셰이더에 SKIN / MATRIX_SKIN 키워드 추가

스키닝 셰이더를 작성할 때 본 행렬 UBO 선언, 어트리뷰트 바인딩, 스키닝 행렬 계산식을 전부 직접 써야 할텐데, 엔진에 커스텀 셰이더 파서가 있는 만큼 SKIN과 MATRIX_SKIN 두 키워드를 셰이더 언어에 추가해서 이 작업을 자동으로 수행되게 했다.

SKIN 키워드

SKIN 키워드를 쓰면 파서가 자동으로 세 가지 일을 한다.

  1. BONE_WEIGHTS / BONE_INDICES 버텍스 어트리뷰트를 등록한다.
  2. SKIN UBO를 생성한다. (set = Object(1), binding = (빈 오브젝트 유니폼 슬롯), mat4 ibm[128] 배열 포함)
  3. ShaderPassskinBinding을 기록해서 런타임이 어느 슬롯에 본 행렬을 올려야 하는지 알 수 있게 한다.
// Before: SKIN키워드가 없다면 직접 바인딩 번호를 지정해야 할 것이다.
drawable->GetMaterialData().SetUniformData(pass, Type::Object, 0, ...);

// After: 셰이더 파싱 결과를 그대로 사용
drawable->GetMaterialData().SetUniformData(pass, Type::Object, pass.GetSkinBinding(), ...);

MATRIX_SKIN 키워드

MATRIX_SKIN은 한 단계 더 나아간 편의 키워드다. 셰이더 코드 맨 앞에 아래 계산식을 자동으로 삽입해준다.
키워드라기보단 전처리기의 매크로에 가까울 것 같다.

mat4 MATRIX_SKIN =
    BONE_WEIGHTS.x * SKIN.ibm[BONE_INDICES.x] +
    BONE_WEIGHTS.y * SKIN.ibm[BONE_INDICES.y] +
    BONE_WEIGHTS.z * SKIN.ibm[BONE_INDICES.z] +
    BONE_WEIGHTS.w * SKIN.ibm[BONE_INDICES.w];

스키닝 셰이더에서 버텍스를 변환할 때 MATRIX_SKIN이라고만 쓰면 4개 본의 가중 합산 행렬을 그냥 쓸 수 있다.
또한 SKIN 키워드가 하는 셰이더에 어트리뷰트/UBO 등록도 MATRIX_SKIN을 쓰면 같이 처리해준다.

IBM 버그 수정

IBM은 Inverse Bind Matrix로, 바인드 행렬의 역행렬이다.
바인드 행렬은 평상시 포즈(바인드 포즈)의 본의 좌표계를 월드 좌표계로 바꾸는 행렬이다.
정리하자면, 월드 좌표를 바인드 포즈 상태의 로컬 본 좌표계로 바꾸는 행렬이다.

처음 구현할 때 IBM을 glm::inverse(bone->localToWorldMatrix)로 런타임에 계산했는데, 이건 틀렸다.
구현할 당시에는 어차피 월드에 처음 놔질 때가 바인드 포즈 아닌가? 하는 생각에 저렇게 했었는데..

런타임 계산 방식은 현재 본의 월드 행렬 역행렬을 구하므로 본이 조금이라도 움직이면 값이 달라진다는 것을 간과했다.
즉, 월드를 다시 불러오거나 포즈를 지정해둔 오브젝트를 복사할 때 IBM이 달라지게 될 것이다.

따라서 glTF가 모델을 불러올 때 제공하는 값을 쓰기로 했다.

// Before
void CalculateIBM() {
    for (size_t i = 0; i < bones.size(); ++i)
        inverseBindMatrices[i] = glm::inverse(bones[i]->localToWorldMatrix);
}

// After: glTF IBM을 SkinnedMesh에서 직접 읽음
void Awake() override {
    Super::Awake();
    if (core::IsValid(skinnedMesh))
        inverseBindMatrices = skinnedMesh->GetInverseBindMatrices();
}

Hierarchy

에디터 Hierarchy에서 모델 에셋을 드래그해 씬에 드롭하면, 기존엔 MeshRenderer만 붙였다.
이제 노드에 skeletonIdx >= 0이면 SkinnedMeshRenderer를 붙이고, 전체 GameObject가 생성된 이후 BFS로 다시 순회하면서 Skeleton의 조인트 인덱스를 가지고 실제 bone Transform*으로 변환했다.

  1. 노드 배열 BFS -> GameObject 생성 (MeshRenderer or SkinnedMeshRenderer)
  2. 전체 GameObject 생성 완료
  3. Skeleton 조인트 nodeIdx → bone Transform* 매핑

SubMesh와 멀티 메테리얼

스켈레탈 메쉬를 다루다 보니 하나의 메쉬에 메테리얼이 여러 개 필요한 케이스가 생겼다.
블렌더에서 캐릭터 모델링을 만져보던중 발견했다.
이를 glTF로 불러오면 primitive가 여럿인 형태로 나타나는데, 이를 위해 SubMesh 구조체를 추가했다.

struct SubMesh {
    std::size_t indexOffset = 0;
    std::size_t indexCount = 0;
};

SubMesh를 이용하면 메쉬에서 해당 메테리얼을 갖는 부위를 하나의 버텍스 버퍼에서 index만 다르게 참조하게 하여 다른 메테리얼을 가진 메쉬를 그릴 수 있게 된다!

그리고 MeshRenderer가 SubMesh 배열 크기에 맞춰 여러 메테리얼을 가질 수 있도록 확장했다.
실제 드로우 객체를 뜻하는 Drawable도 메테리얼 하나를 가리키던 구조에서 리스트로 바뀌었고, 드로우 코드를 담당하는 VulkanRenderImpl에서 SubMesh 별로 vkCmdDrawIndexed를 나눠 호출한다.

마무리

이번 작업을 통해 스켈레탈 메쉬 렌더링의 기반을 닦았다.
실제 애니메이션 재생은 아직 없고, 현재는 본 트랜스폼을 수동으로 조작하거나 코드로 직접 설정해야 한다.
다음 단계는 아마 glTF animations를 읽어 애니메이션 클립을 재생하는 것이 될 것 같다.

배운점

  • 유니티도 다중 메테리얼을 처리할 때 메쉬 내부에 SubMesh를 두고 처리한다는 것을 알았다.
  • Inverse Bind Matrix의 정확한 의미
profile
개발하며 배웠던 것들 기록용 블로그

0개의 댓글