Back to notes
    개발 일지엔진렌더링그래픽스스켈레톤 애니메이션애니메이션작성 · 2026-07-07

    우리 엔진 탐방기: 애니메이션이 움직이는 방식

    ALEngine에서 애니메이션이 움직이는 방법을 알아보자

    목차

    왜 이 글을 쓰게 되었을까?

    이전 글에서는 AL Engine에서 한 프레임이 어떤 순서로 만들어지는지 정리했다. 당시에는 렌더링 파이프라인을 단순화해서 이런 식으로 설명했다.

    cpp
    drawFrame()
    {
        renderBackground();
        renderShadowMap();
    
        renderGeometryPass();
        renderLightingPass();
    
        renderCollider();
        renderGUI();
    
        present();
    }
    

    그런데 여기서 한 가지가 빠져 있다. 애니메이션이 궁금해서 들어온 분이라면 헤맸을 것이다.

    캐릭터는 언제 움직이는 걸까?

    renderGeometryPass()에서 모델을 그릴 때는 이미 캐릭터가 현재 자세를 가진 상태다. 그렇다면 애니메이션은 Geometry Pass 안에서 계산되는 걸까?

    애니메이션은 Geometry Pass에서 갑자기 생기는 것이 아니다. 렌더링이 시작되기 전에 CPU에서 현재 포즈를 계산하고, Geometry Pass의 Vertex Shader에서 Bone Matrix를 이용해 정점에 적용된다. 이번 글에서는 AL Engine에서 스켈레탈 애니메이션이 한 프레임 동안 어떤 순서로 갱신되고, 어떻게 GPU Skinning을 거쳐 화면에 나타나는지 정리해보려고 한다.


    먼저 코드 흐름으로 보면

    실제 구현은 렌더링 파이프라인보다 조금 더 앞에서 시작된다. 단순화하면 한 프레임의 흐름은 이렇게 볼 수 있다.

    cpp
    frame()
    {
        updateScripts();      // C# / NativeScript onUpdate
        runPhysics();         // 물리 계산 및 Transform 동기화
    
        ===> updateAnimation();    // 현재 시간 계산, 키프레임 보간, Bone Matrix 생성
    
        renderBackground();
        renderShadowMap();
    
        renderGeometryPass(); // Bone Matrix 업로드 + Vertex Shader에서 GPU Skinning
        renderLightingPass();
    
        renderCollider();
        renderGUI();
    
        present();
    }
    

    여기서 중요한 점은 updateAnimation()이 렌더링 전에 호출된다는 점이다. 즉 애니메이션은 화면을 그리는 순간에 처음 계산되는 것이 아니라, 렌더링 전에 먼저 현재 프레임의 포즈를 만들어둔다.

    그 후 Geometry Pass에서 모델을 그릴 때, 계산된 Bone Matrix를 Vertex Shader가 사용해 정점 위치를 변형한다. 조금 더 실제 코드 흐름에 가깝게 보면 다음과 같다.

    cpp
    Scene::onUpdateRuntime(ts)
    {
        // 1. 스크립트 업데이트
        // C# Script / NativeScript의 onUpdate()가 먼저 호출된다.
        updateScripts(ts);
    
        // 2. 물리 업데이트
        // 물리 시뮬레이션 이후 Rigidbody 결과를 Transform에 동기화한다.
        runPhysics(ts);
    
        // ...
    
        // 3. 스켈레탈 애니메이션 업데이트
        // 렌더링이 시작되기 전에 현재 프레임의 애니메이션 포즈를 먼저 계산한다.
        for (auto& entity : SkeletalAnimatorComponents)
        {
            // entity에서 SkeletalAnimatorComponent를 가져온다.
            auto& animator = entity.get<SkeletalAnimatorComponent>();
    
            // 실제 애니메이션 계산은 SAComponent가 담당한다.
            // 현재 시간 갱신, 키프레임 보간, Bone Matrix 계산이 이 안에서 일어난다.
            animator.saComponent->updateAnimation(ts * animator.speedFactor, 0);
        }
    
        // ...
    
        // 4. 렌더링 시작
        // 이 시점에는 각 애니메이션 Entity의 현재 Pose가 이미 계산되어 있다.
        Renderer::beginScene(scene, mainCamera);
    }
    

    1. 애니메이션은 결국 T-pose를 움직이는 과정이다

    대부분의 스켈레탈 모델은 기본 자세를 가지고 있다.

    그림 1:언리얼 T본 자세
    그림 1:언리얼 T본 자세

    게임을 하다 보면 버그로 인해 캐릭터가 양팔을 벌린 T-pose 상태로 멈춰 있는 모습을 볼 때가 있다. 이건 우연히 이상한 자세가 나온 것이 아니라, 모델이 가지고 있는 기본 자세가 그대로 화면에 보인 것이다. 스켈레탈 애니메이션은 이 기본 자세에서 출발한다. 모델은 처음부터 걷거나 뛰거나 공격하는 상태로 저장되어 있는 것이 아니다. 기본 자세의 메시와 본 구조를 가지고 있고, 애니메이션 데이터는 시간에 따라 각 본이 어떻게 이동하고, 회전하고, 스케일되는지를 저장한다.

    즉 애니메이션은 “새로운 모델을 계속 만드는 과정”이 아니라, 기본 자세에 있는 본들을 매 프레임 다른 값으로 변형하는 과정이다. 예를 들어 걷기 애니메이션이라면 특정 시간마다 다리 본, 팔 본, 허리 본의 Transform 값이 달라진다. 엔진은 현재 애니메이션 시간이 어디인지 계산하고, 해당 시간에 맞는 본의 위치와 회전 값을 구한다. 그리고 그 결과를 이용해 T-pose 상태의 메시를 현재 포즈로 변형한다. 정리하면 기본 흐름은 이렇다.

    기본 자세(T-pose / Bind Pose)
    현재 애니메이션 시간 계산
    키프레임 데이터에서 현재 시간의 Transform 계산
    본 계층 구조를 따라 최종 Bone Matrix 생성
    GPU에서 정점 변형
    움직이는 캐릭터로 렌더링
    

    그래서 애니메이션이 “언제 움직이냐”는 질문에 대한 첫 번째 답은 이렇다. 캐릭터는 원래 기본 자세로 존재하고, 엔진이 매 프레임 현재 시간의 포즈를 계산해 그 기본 자세를 변형할 때 비로소 움직인다.


    2. glTF 안에는 어떤 애니메이션 데이터가 들어 있을까?

    그렇다면 이 “시간에 따른 본의 변화”는 어디에 저장되어 있을까? AL Engine에서는 glTF 모델을 사용했기 때문에, 애니메이션 데이터 역시 glTF의 Animation Clip, Sampler, Channel 구조를 통해 읽어왔다. 처음에는 이 구조가 꽤 헷갈렸다.

    애니메이션 클립 안에 키프레임이 있고, 그 키프레임이 본에 바로 연결되어 있을 것 같았는데, 실제로는 Sampler와 Channel이 역할을 나누고 있었다. 간단히 보면 이런 구조다.

    Animation Clip
     ├─ Sampler
     │   ├─ Input  : 시간 값 배열
     │   ├─ Output : Transform 값 배열
     │   └─ Interpolation : 보간 방식
     └─ Channel
         ├─ Target Node : 어떤 본에 적용할지
         └─ Path        : translation / rotation / scale 중 무엇을 바꿀지
    

    Sampler는 “언제 어떤 값이 되는가”를 가지고 있다. 예를 들어 시간 배열이 다음과 같다고 해보자.

    Input Time
    0.0s → 0.5s → 1.0s
    

    Output에는 각 시간에 해당하는 Transform 값이 들어 있다. Channel은 이 값을 어디에 적용할지 알려준다.

    예를 들어 어떤 Channel이 Arm.L 본의 rotation을 가리킨다면, 해당 Sampler의 Output 값은 왼팔 본의 회전 값으로 사용된다. 즉 Sampler와 Channel의 관계는 이렇게 이해할 수 있다.

    Sampler = 시간에 따른 값
    Channel = 그 값을 적용할 대상
    

    이 구조 덕분에 하나의 애니메이션 클립 안에서 여러 본의 위치, 회전, 스케일을 각각 따로 제어할 수 있다.


    3. 현재 시간의 자세는 어떻게 계산될까?

    애니메이션을 재생하려면 먼저 현재 애니메이션 시간이 필요하다. AL Engine에서는 SkeletalAnimation::update() 안에서 현재 키프레임 시간이 갱신된다.

    cpp
    SkeletalAnimation::update(timestep, skeleton)
    {
        // 현재 애니메이션 재생 시간을 증가시킨다.
        m_CurrentKeyFrameTime += timestep;
    
        // 애니메이션 클립 안의 모든 Channel을 순회한다.
        // Channel은 "어떤 본의 어떤 속성(이동/회전/스케일)을 바꿀지"를 나타낸다.
        for (auto& channel : m_Channels)
        {
            // 현재 시간이 어느 두 키프레임 사이에 있는지 찾는다.
            int i = findKeyframeInterval(m_CurrentKeyFrameTime);
    
            // 두 키프레임 사이에서 현재 시간이 얼마나 진행되었는지 계산한다.
            float a =
                (m_CurrentKeyFrameTime - timestamps[i]) /
                (timestamps[i + 1] - timestamps[i]);
    
            // Channel의 대상에 따라 Translation / Rotation / Scale을 갱신한다.
            // Translation, Scale은 mix
            // Rotation은 slerp를 사용한다.
            applyInterpolatedTransform(channel, i, a, skeleton);
        }
    }
    

    이후 각 Channel을 순회하면서 현재 시간이 어느 키프레임 구간에 속하는지 찾는다. 예를 들어 걷기 애니메이션이 1초짜리라고 해보자.

    0.0초  → 첫 번째 자세
    0.5초  → 두 번째 자세
    1.0초  → 세 번째 자세
    

    현재 시간이 0.35초라면, 엔진은 0.0초와 0.5초 사이의 값을 계산해야 한다. 보간 계수는 대략 이런 식으로 구할 수 있다.

    cpp
    // 현재 시간이 두 키프레임 사이에서 어느 정도 위치하는지 계산한다.
    // a가 0에 가까우면 이전 키프레임에 가깝고,
    // a가 1에 가까우면 다음 키프레임에 가깝다.
    a = (t - timestamps[i]) / (timestamps[i + 1] - timestamps[i]);
    

    이 값은 현재 시간이 두 키프레임 사이에서 어느 정도 진행되었는지를 의미한다. 그 다음 각 Transform 타입에 맞게 보간한다.

    cpp
    // 위치는 두 위치 값을 선형 보간한다.
    bone.m_DeformedNodeTranslation = mix(fromPosition, toPosition, a);
    
    // 회전은 Quaternion을 사용하므로 slerp로 보간한다.
    // 단순 선형 보간보다 중간 회전이 자연스럽다.
    bone.m_DeformedNodeRotation = slerp(fromRotation, toRotation, a);
    
    // 스케일도 위치와 마찬가지로 선형 보간한다.
    bone.m_DeformedNodeScale = mix(fromScale, toScale, a);
    

    위치와 스케일은 선형 보간을 사용했고, 회전은 Quaternion 기반의 slerp를 사용했다. 처음에는 위치, 회전, 스케일을 모두 비슷하게 섞으면 될 것 같았지만, 회전은 단순 선형 보간으로 처리하면 중간 자세가 어색해질 수 있다. 그래서 회전은 구면 선형 보간을 사용하는 것이 더 자연스럽다.

    이 과정을 통해 현재 시간에 해당하는 각 본의 Local Transform이 만들어진다. 정리하면 애니메이션은 매 프레임 다음 과정을 반복한다.

    현재 시간 계산
    이 시간이 어느 키프레임 사이인지 찾기
    두 키프레임 사이의 값 보간
    현재 시간의 본 Transform 생성
    

    즉 애니메이션이 움직인다는 것은, 매 프레임 현재 시간에 맞춰 본의 Transform을 새로 계산한다는 뜻이다.


    4. 본 계층 구조는 왜 필요할까?

    각 본의 Transform을 계산했다고 해서 끝나는 것은 아니다. 스켈레탈 모델의 본은 계층 구조를 가진다.

    그림 2. 언리얼 본 계층 구조
    그림 2. 언리얼 본 계층 구조

    예를 들어 팔 구조를 단순하게 보면 이런 식이다.

    Root
     └─ Spine
         └─ Shoulder
             └─ Arm
                 └─ Hand
    

    손은 혼자 움직이지 않는다. 팔이 움직이면 손도 따라 움직이고, 어깨가 움직이면 팔과 손이 함께 움직인다. 그래서 각 본의 Local Transform만으로는 최종 위치를 알 수 없다.

    각 본은 자기 자신의 Transform을 가지고 있지만, 실제 위치는 부모 본들의 Transform을 모두 반영해야 한다. AL Engine에서는 Skeleton::update() 안에서 먼저 각 본의 Local Matrix를 만든 뒤, Root Joint부터 재귀적으로 내려가며 부모 Transform을 누적했다. 개념적으로 보면 이런 식이다.

    cpp
    Skeleton::update()
    {
        // 1. 각 본의 현재 Local Transform을 Matrix로 변환한다.
        for (auto& bone : bones)
        {
            bone.FinalMatrix =
                translate(bone.translation)
                * mat4(bone.rotation)
                * scale(bone.scale);
        }
    
        // ...
    
        // 2. Root Bone부터 자식 Bone으로 내려가며 부모 Transform을 누적한다.
        // 자식 본의 최종 위치는 부모 본의 움직임을 함께 반영해야 한다.
        updateBone(ROOT_JOINT);
    }
    
    updateBone(parent)
    {
        for (auto& child : parent.children)
        {
            FinalMatrix[child] =
                FinalMatrix[parent] * FinalMatrix[child];
    
            updateBone(child);
        }
    }
    

    예를 들어 Hand의 최종 위치를 구하려면 Hand 자신의 Transform뿐만 아니라 Arm, Shoulder, Spine, Root의 Transform이 모두 누적되어야 한다. 이 과정을 모든 본에 대해 수행하면, 현재 포즈에서 각 본이 모델 공간에서 어디에 있는지 계산할 수 있다.

    이쯤되면 그럼 최상단(루트) 노드가 어디일까 궁금해질 수 있는데, 의외로 골반(Pelvis)이다.


    5. 최종 Bone Matrix는 어떻게 만들어질까?

    현재 포즈에서 각 본의 Global Transform을 구했다고 해서 바로 정점에 적용할 수 있는 것은 아니다. 스켈레탈 애니메이션에서는 Inverse Bind Matrix라는 값이 함께 사용된다. Bind Pose는 모델이 처음 스켈레톤에 묶여 있던 기준 자세다.

    대부분의 경우 T-pose에 가까운 기본 자세라고 볼 수 있다. Inverse Bind Matrix는 이 기준 자세에서의 본 Transform을 되돌리는 행렬이다. 처음에는 이 부분이 가장 헷갈렸다.

    현재 본 위치를 계산했으면 그냥 적용하면 되는 것 아닌가?”라고 생각했는데, 실제로는 메시 정점이 이미 Bind Pose 기준으로 스켈레톤에 묶여 있기 때문에 기준 자세를 제거하고 현재 자세를 적용하는 과정이 필요했다. AL Engine에서도 부모 Transform을 누적한 뒤, 마지막에 각 본의 Inverse Bind Matrix를 곱해 최종 Bone Matrix를 만들었다.

    cpp
    // 3. Bind Pose 기준을 제거하고 현재 Pose로 변환하기 위해
    // 각 본의 Inverse Bind Matrix를 마지막에 적용한다.
    FinalMatrix[i] *= bones[i].m_InverseBindMatrix;
    

    이 행렬은 쉽게 말하면 다음 의미를 가진다.

    기준 자세에 묶여 있던 정점을
    현재 애니메이션 자세로 이동시키기 위한 변환
    

    이렇게 만들어진 최종 Bone Matrix 배열은 SAComponent::m_CurrentPose에 저장된다. 즉 CPU 단계의 결과물은 “현재 프레임에서 사용할 Bone Matrix 배열”이다.


    6. Bone Matrix는 언제 GPU로 넘어갈까?

    개념적으로 보면 애니메이션 계산 흐름은 이렇게 생각하기 쉽다.

    cpp
    updateAnimation();
    uploadBoneMatrices();
    renderGeometryPass();
    

    구조만 놓고 보면 이런 식으로 애니메이션 계산과 GPU 업로드 단계를 분리하는 편이 이해하기 쉽다. 애니메이션 시스템은 현재 포즈를 계산하고, 렌더링 시스템은 그 결과를 받아 GPU에 전달하는 식으로 역할이 나뉘기 때문이다. 하지만 AL Engine의 실제 구조는 조금 달랐다.

    Bone Matrix를 업로드하는 별도 단계가 분리되어 있지는 않았고, Geometry Pass에서 Mesh를 그릴 때 현재 Pose를 가져와 Vertex UBO에 함께 담아 전달하는 방식으로 동작했다. 개념적으로 보면 이런 흐름이다.

    cpp
    recordGeometryPass()
    {
        for (auto& entity : MeshRendererEntities)
        {
            // ...
    
            // 현재 Entity가 스켈레탈 애니메이션을 가지고 있다면,
            // 앞에서 계산해둔 현재 Pose를 가져온다.
            if (entity.has<SkeletalAnimatorComponent>())
            {
                auto& animator = entity.get<SkeletalAnimatorComponent>();
                drawInfo.finalBonesMatrices =
                    animator.saComponent->getCurrentPose();
            }
            else
            {
                // 애니메이션이 없는 모델은 Identity Matrix를 사용한다.
                // 이렇게 하면 같은 Shader를 사용해도 정점이 변형되지 않는다.
                drawInfo.finalBonesMatrices = identityBoneMatrices;
            }
    
            // ...
    
            // Vertex Shader에서 사용할 Bone Matrix를 UBO에 담는다.
            vertexUBO.finalJointsMatrices = drawInfo.finalBonesMatrices;
    
            // 이 시점에 CPU에서 계산된 Bone Matrix가 GPU가 읽을 수 있는 버퍼로 전달된다.
            updateUniformBuffer(vertexUBO);
    
            // 실제 정점 변형은 여기서 바로 일어나는 것이 아니라,
            // 이후 GPU가 Vertex Shader를 실행할 때 일어난다.
            vkCmdDraw();
        }
    }
    

    여기서 중요한 점은 Bone Matrix가 계산되는 시점과 실제 정점이 변형되는 시점이 다르다는 것이다. CPU는 렌더링 전에 현재 프레임의 Bone Matrix를 계산해두고, Geometry Pass에서 이를 GPU로 전달한다. 그리고 실제 정점 변형은 CPU 코드에서 바로 일어나는 것이 아니라, GPU가 Vertex Shader를 실행할 때 일어난다.


    7. 왜 정점 변형은 GPU에서 처리할까?

    CPU에서 Bone Matrix까지 계산했다면, 정점 위치까지 CPU에서 전부 계산해도 되지 않을까? 가능은 하다. 하지만 실시간 렌더링에서는 효율적이지 않다.

    캐릭터 하나에는 많은 정점이 있고, 각 정점은 하나의 본만 따르는 것이 아니라 여러 본의 영향을 함께 받을 수 있다. 예를 들어 팔꿈치 근처의 정점은 위팔 본과 아래팔 본의 영향을 동시에 받을 수 있다. AL Engine의 Vertex Shader에서는 각 정점이 가진 Bone ID와 Weight를 이용해 최종 위치를 계산했다.

    glsl
    vec4 animatedPosition = vec4(0.0);
    mat4 boneTransform = mat4(0.0);
    
    // 하나의 정점은 여러 Bone의 영향을 받을 수 있다.
    // AL Engine에서는 정점 하나당 최대 4개의 Bone 영향만 사용했다.
    for (int i = 0; i < MAX_BONE_INFLUENCE; ++i)
    {
        // 현재 정점에 영향을 주는 i번째 Bone의 행렬을 가져온다.
        mat4 jointMatrix = ubo.finalJointsMatrices[inBoneIds[i]];
    
        // Bone Matrix를 현재 정점 위치에 적용한다.
        vec4 local = jointMatrix * vec4(inPosition, 1.0);
    
        // Bone Weight만큼 결과를 누적한다.
        // 여러 Bone의 변환 결과를 가중합해 최종 위치를 만든다.
        animatedPosition += local * inWeights[i];
    
        // Normal 변환 등에 사용할 Bone Transform도 같은 방식으로 누적한다.
        boneTransform += jointMatrix * inWeights[i];
    }
    
    // 최종적으로 Model / View / Projection 변환을 적용해 화면 좌표로 보낸다.
    gl_Position =
        ubo.proj
        * ubo.view
        * ubo.model
        * animatedPosition;
    

    이 코드는 각 정점이 영향을 받는 본을 확인하고, 각 본의 변환 결과를 Weight만큼 섞어 최종 정점 위치를 만든다. 여기서 MAX_BONE_INFLUENCE는 4로 두었다. 이건 스켈레톤 구조상 본이 4개까지만 연결될 수 있다는 뜻은 아니다. 하나의 본은 여러 자식 본을 가질 수 있고, 스켈레톤 계층 구조 자체에는 그런 제한이 없다.

    여기서 말하는 4개 제한은 정점 하나가 영향을 받을 수 있는 Bone 개수를 제한한 것이다. 실제 모델에서 하나의 정점은 여러 Bone의 영향을 받을 수 있지만, 모든 영향을 전부 계산하면 Vertex Shader 비용과 버퍼 크기가 커진다. 그래서 보통 영향도가 큰 상위 4개 Bone만 사용한다. ivec4 형태의 Bone ID와 vec4 형태의 Weight로 처리하기도 좋아 GPU Skinning에서 자주 사용하는 방식이다.

    즉 CPU는 현재 프레임의 Bone Matrix를 계산하고, GPU는 각 정점마다 최대 4개의 Bone Matrix와 Weight를 적용해 최종 위치를 만든다. 정리하면 역할은 이렇게 나뉜다.

    CPU
    - 현재 시간 계산
    - 키프레임 보간
    - 본 계층 계산
    - Bone Matrix 생성
    
    GPU
    - 각 정점의 Bone ID와 Weight 확인
    - 최대 4개의 Bone Matrix 적용
    - 변형된 정점 위치 출력
    

    이 과정을 GPU Skinning이라고 한다.


    8. 구현하면서 헷갈렸던 부분

    이 구조를 만들면서 가장 헷갈렸던 부분은 “무엇을 공유하고, 무엇을 Entity마다 독립시켜야 하는가”였다. Mesh나 Material 같은 리소스는 여러 Entity가 공유해도 된다. 하지만 현재 재생 시간, 반복 여부, 역재생 여부, 현재 Bone Matrix 같은 Runtime State는 Entity마다 독립적이어야 한다.

    당시 구조에서는 SkeletalAnimation 내부에 Sampler / Channel 같은 불변 클립 데이터와 m_CurrentKeyFrameTime 같은 가변 상태가 함께 존재했다. 이 때문에 같은 모델을 여러 Entity가 공유할 때, 애니메이션 상태까지 함께 공유될 수 있는 구조가 되었다. 결과적으로 같은 모델을 사용하는 여러 Entity가 서로 독립적으로 애니메이션을 재생하지 못하고, 같은 동작을 보이는 문제가 발생했다. 이 부분은 글이 길어질 수 있어 여기서는 자세히 다루지 않으려고 한다. 핵심은 애니메이션 시스템에서 Resource와 Runtime State를 분리하는 것이 중요하다는 점이었다.

    관련 개발 기록: 고블린 세 마리가 같은 영혼을 공유한 이유


    9. 블렌딩은 어디서 들어갈까?

    여기까지는 하나의 애니메이션 클립을 재생하는 흐름에 가깝다. 예를 들어 걷기 애니메이션 하나를 재생한다면, 현재 시간에 맞는 키프레임을 찾고, 보간하고, Bone Matrix를 만들어 GPU에 전달하면 된다. 하지만 실제 게임에서는 애니메이션 하나만 재생하지 않는다.

    캐릭터는 Idle 상태에 있다가 달리기도 하고, 점프하기도 하고, 공격하기도 한다. 이때 단순히 Idle 애니메이션에서 Run 애니메이션으로 바로 바꾸면 자세가 갑자기 튈 수 있다.

    영상 1. 블렌딩 없는 애니메이션 전환
    그래서 두 애니메이션의 Pose를 일정 시간 동안 섞는 과정이 필요하다.

    이것이 Animation Blending이다.

    영상 2. 블렌딩되는 애니메이션

    AL Engine에도 blendUpdate() 흐름이 있었고, 이전 애니메이션과 현재 애니메이션을 각각 계산한 뒤 Bone별로 Transform을 섞는 구조를 사용했다. 다만 블렌딩은 단순히 “두 값을 섞는다”에서 끝나지 않는다. 언제 전환할지, 얼마나 오래 섞을지, 전환 중 다시 다른 상태가 들어오면 어떻게 할지 같은 문제가 함께 따라온다.

    이건 State Machine과 연결되는 주제다. 그래서 다음 글에서는 이 부분을 따로 다뤄보려고 한다.


    10. 정리: 애니메이션은 언제 움직일까?

    이 글의 질문은 “애니메이션은 언제 움직일까?”였다. 정리하면 흐름은 이렇다.

    1. glTF에서 Animation Clip / Sampler / Channel / Skeleton 정보를 읽는다.
    2. CPU에서 현재 애니메이션 시간을 계산한다.
    3. 현재 시간이 위치한 키프레임 구간을 찾는다.
    4. 보간을 통해 현재 시간의 Local Pose를 만든다.
    5. Skeleton 계층을 따라 부모 Transform을 누적한다.
    6. Inverse Bind Matrix를 적용해 최종 Bone Matrix를 만든다.
    7. Bone Matrix는 Geometry Pass에서 UBO를 통해 GPU로 전달된다.
    8. Vertex Shader에서 Bone Weight를 적용해 메시 정점을 변형한다.
    9. 변형된 메시가 Geometry Pass를 지나 화면에 렌더링된다.
    

    그래서 애니메이션은 Geometry Pass에서 갑자기 생기는 것이 아니다. CPU에서 현재 시간의 포즈를 계산하고, Geometry Pass에서 Bone Matrix를 GPU에 전달한 뒤, Vertex Shader가 정점을 변형하는 순간 화면에 나타난다. 처음에는 렌더링 파이프라인 안에서 애니메이션이 어디에 들어가는지 감이 잘 오지 않았다.

    하지만 실제 코드 흐름을 따라가보니 애니메이션은 렌더링과 따로 떨어진 기능이 아니었다. 렌더링 직전에 CPU에서 현재 포즈를 만들고, Geometry Pass 안에서 GPU Skinning으로 정점에 적용되는 흐름이었다. 이번 글에서는 단일 애니메이션 클립이 어떻게 현재 포즈로 계산되고, 어떻게 GPU에서 메시를 움직이는지에 집중했다. 다음 글에서는 이 포즈들이 어떻게 상태에 따라 전환되고, 어떻게 자연스럽게 섞이는지 정리해보려고 한다.

    다음 글: 상태 전환이 없다면 사실상 시체다