컨텐츠 검색
[DirectX 11] 3. 스프라이트 심화: 애니메이션 원리와 트랜스폼

2026. 2. 1. 22:31DirectX 스터디

지난 포스팅에서는 SpriteBatch를 이용해 화면에 이미지를 띄우는 기본적인 파이프라인을 구축했습니다. 이번에는 이 정적인 이미지에 생명력을 불어넣는 애니메이션(Animation)과, 크기·회전·위치를 제어하는 변환(Transformation)의 원리를 분석해보았습니다.

단순히 라이브러리 기능을 호출하는 것을 넘어, 스프라이트 시트(Sprite Sheet)가 메모리 효율성에 미치는 영향과 피벗(Origin)이 행렬 연산에 미치는 영향을 이해하는 것이 목표입니다.


1. 핵심 학습 키워드

① Source RECT (소스 사각형)

  • 정의: 전체 텍스처 이미지 중에서 "실제로 그릴 부분만 잘라낸" 사각형 영역입니다.
  • 분석: GPU는 거대한 텍스처 하나를 메모리에 올리고, UV 좌표만 바꿔가며 그리는 것이 작은 텍스처 여러 개를 교체(Binding)하는 것보다 훨씬 빠릅니다. 이 스프라이트 시트 기법의 핵심 구현체가 바로 Source RECT입니다.

② Origin (중심점, Pivot)

  • 정의: 회전(Rotation)과 크기 조절(Scaling)의 기준점이 되는 좌표입니다.
  • 분석: 기본값인 (0, 0)은 이미지의 좌상단을 의미합니다. 이 상태에서 회전하면 이미지가 제자리에서 도는 게 아니라, 좌상단을 축으로 크게 원을 그리며 돕니다. 우리가 원하는 '제자리 회전'을 구현하려면 Origin을 이미지의 중앙으로 옮겨야 합니다.

③ Z-Order와 SpriteSortMode

  • 정의: 2D 그래픽에서 물체의 앞뒤 관계(깊이)를 결정하는 방식입니다.
  • 분석: SpriteBatch는 기본적으로 Deferred(지연) 모드를 사용합니다. 이는 그리기 명령을 모아뒀다가 제출하는데, 이때 그리는 순서(Draw Call 순서)가 곧 깊이가 됩니다(Painter's Algorithm). 하지만 SpriteSortMode::BackToFront 등을 사용하면 Z값(LayerDepth)에 따라 자동으로 정렬하게 만들 수도 있습니다.

2. 애니메이션의 구현 원리: 프레임 슬라이싱

애니메이션은 실제로 움직이는 것이 아니라, 연속된 이미지를 빠르게 교체하여 보여주는 착시 현상입니다. 이를 구현하기 위해 스프라이트 시트RECT 연산을 사용합니다.

// 1. 시간 흐름에 따른 프레임 인덱스 갱신 (Update)
void AnimatedTexture::Update(float elapsed)
{
    if (mPaused) return;

    mTotalElapsed += elapsed;

    // 지정된 시간(mTimePerFrame)이 지나면 다음 프레임으로 넘어갑니다.
    if (mTotalElapsed > mTimePerFrame)
    {
        ++mFrame;
        mFrame = mFrame % mFrameCount; // 마지막 프레임 이후엔 다시 0번으로 순환(Loop)
        mTotalElapsed -= mTimePerFrame;
    }
}

// 2. 현재 프레임에 맞는 영역(RECT) 계산 (Draw)
void AnimatedTexture::Draw(DirectX::SpriteBatch* batch, const DirectX::XMFLOAT2& screenPos) const
{
    // 전체 텍스처 너비를 총 프레임 수로 나누어, '한 프레임의 너비'를 구합니다.
    // (가로로 긴 스트립 형태의 스프라이트 시트를 가정)
    int frameWidth = mTextureWidth / mFrameCount;

    RECT sourceRect;
    // Left: 현재 프레임 번호만큼 오른쪽으로 이동
    sourceRect.left = frameWidth * mFrame;
    // Top: 한 줄로 되어있으므로 항상 0
    sourceRect.top = 0;
    
    sourceRect.right = sourceRect.left + frameWidth;
    sourceRect.bottom = mTextureHeight; // 높이는 이미지 전체 사용

    // 계산된 sourceRect를 이용해 해당 부분만 잘라서 그립니다.
    batch->Draw(mTexture.Get(), screenPos, &sourceRect, DirectX::Colors::White,
    mRotation, mOrigin, mScale, DirectX::SpriteEffects_None, mDepth);
}

분석:
이 코드는 텍스처를 물리적으로 자르는 것이 아닙니다. 셰이더에게 "이번 프레임에는 텍스처의 (left, top)부터 (right, bottom)까지만 읽어서 그려라"라고 UV 좌표 범위를 지정해주는 과정입니다.

 

  • Horizontal Strip 구조: 이 코드는 애니메이션 프레임이 가로 한 줄로 배치된 이미지를 가정합니다. 따라서 sourceRect.top은 항상 0이며, sourceRect.left 값만 frameWidth 단위로 이동하며 애니메이션을 만듭니다.
  • 상태 관리: mPaused, mFrame, mTotalElapsed 변수를 통해 애니메이션의 재생/정지 상태와 속도를 정교하게 제어하는 구조입니다.

 

 


3. 변환(Transform)의 마법: Origin의 중요성

이미지를 회전시키거나 확대/축소할 때 가장 많이 하는 실수가 바로 Origin(중심점) 설정을 간과하는 것입니다. 이 섹션에서는 단순한 이미지 출력을 넘어, 행렬 연산의 기준점(Pivot)을 제어하는 원리를 분석했습니다.

① Origin(중심점)이란?

  • 정의: 회전(Rotation)과 크기 조절(Scaling)이 일어나는 '기준 좌표'입니다.
  • 분석: SpriteBatch의 기본 Origin은 (0, 0) 즉, 이미지의 좌상단(Top-Left)입니다. 이 상태에서 회전을 적용하면 이미지가 제자리에서 도는 것이 아니라, 좌상단을 축으로 크게 원을 그리며 쏠리는 현상이 발생합니다. 우리가 흔히 생각하는 '제자리 회전'을 구현하려면 Origin을 이미지의 중앙으로 보정해줘야 합니다.

② Code: Origin 보정 전후 비교

단순히 Draw 함수에 값을 넣는 것이 아니라, 이것이 내부적으로 Local Space의 기준점을 이동시키는 연산임을 이해해야 합니다.

// 상황: 우주선(Ship)을 제자리에서 빙글빙글 돌리고 싶음

// [Bad Case] Origin 설정 없이 회전 (기본값 0,0 사용)
// 결과: 우주선이 제자리가 아니라, 왼쪽 위 모서리를 핀으로 꽂은 채 크게 원을 그리며 돕니다.
// 의도한 위치(pos)에서 벗어나게 됩니다.
m_spriteBatch->Draw(texture.Get(), pos, nullptr, Colors::White, 
    rotation, Vector2(0, 0), scale); 

// ---------------------------------------------------------

// [Good Case] Origin을 이미지의 중앙으로 설정 (Center Pivot)
// 1. 텍스처 너비와 높이의 절반을 구합니다.
Vector2 origin;
origin.x = textureWidth / 2.f;
origin.y = textureHeight / 2.f;

// 2. Draw 호출 시 origin을 전달합니다.
// 결과: 우주선이 정확히 중심점을 축으로 제자리에서 회전합니다.
m_spriteBatch->Draw(texture.Get(), pos, nullptr, Colors::White, 
    rotation, origin, scale);
  • 기술적 의미: 이 과정은 수학적으로 SRT 변환(Scale-Rotation-Translation) 행렬을 만들 때, 회전 행렬 적용 전 이미지를 원점으로 이동시키는 오프셋(Offset) 역할을 수행합니다.

 


4. 심화: 무한 스크롤 배경

슈팅 게임이나 러닝 게임에서 배경이 끝없이 이어지는 연출은 어떻게 할까요?

핵심은 "화면보다 조금 더 큰 영역을 잡아, 빈 공간 없이 이미지를 계속 이어 붙이는(Tiling) 것"입니다.

이를 구현하기 위해 앞서 배운 Origin 개념과 루프 로직을 결합하여, 수직 무한 스크롤을 구현해보았습니다.

 

① 구현 전략: Pre-fill & Loop

배경이 위에서 아래로 흐른다고 가정할 때, 단순히 현재 위치부터 그리면 화면 위쪽에 빈 공간이 생길 수 있습니다. 이를 방지하기 위해 "항상 화면보다 한 칸 위에서 시작하여 채워 내려오는 방식"을 채택했습니다.

 

② Code: 오프셋 보정 및 무한 타일링

void ScrollingBackground::Draw(SpriteBatch* batch) const
{
    XMVECTOR screenPos = XMLoadFloat2(&mScreenPos);
    XMVECTOR origin = XMLoadFloat2(&mOrigin); // (Width/2, 0) 상단 중앙 기준
    XMVECTOR textureSize = XMLoadFloat2(&mTextureSize); 

    // [Step A] 상단 빈 공간 방지 (Pre-fill Top)
    // 현재 위치(mScreenPos)가 화면 중간일 경우, 위쪽이 비어 보일 수 있습니다.
    // 따라서 텍스처 높이만큼 '위쪽'으로 이동시킨 위치에서 첫 번째 타일을 그립니다.
    screenPos -= textureSize;
    batch->Draw(mTexture.Get(), screenPos, nullptr, Colors::White, 0.f, origin, g_XMOne, SpriteEffects_None, 0.f);

    // [Step B] 화면 채우기 루프 (Infinite Tiling)
    // 방금 그린 타일의 끝(바닥) 위치를 기준으로 잡습니다.
    int currentPixelY = static_cast<int>(XMVectorGetY(screenPos));
    
    // 현재 그리는 위치가 화면 아래 끝(mScreenHeight)을 넘어설 때까지 계속 이어 붙입니다.
    while (currentPixelY < mScreenHeight)
    {
        // 다음 타일 위치로 이동 (아래로 한 칸)
        screenPos += textureSize;
        currentPixelY += mTextureHeight;
        
        // 타일 그리기
        batch->Draw(mTexture.Get(), screenPos, nullptr, Colors::White, 0.f, origin, g_XMOne, SpriteEffects_None, 0.f);
    }
}

③ 로직 분석

  1. Origin 전략: 배경의 Origin은 캐릭터와 달리 (Width/2, 0)으로 설정했습니다. 가로는 중앙 정렬을 유지하되, 세로는 상단(Top)을 기준으로 삼아 좌표 계산을 단순화하기 위함입니다.
  2. While 루프의 유연성: 화면 해상도가 4K처럼 매우 커져서 텍스처 한두 장으로 커버가 안 되더라도, while 루프가 바닥에 닿을 때까지 자동으로 그려주므로 해상도 변화에 유연하게 대응합니다.

5. 마무리

이번 학습을 통해 2D 그래픽스의 핵심적인 제어 기법들을 익혔습니다.

  1. Source RECT: 하나의 텍스처를 여러 프레임으로 나눠 쓰는 원리 (메모리 최적화).
  2. Origin: 회전과 크기 조절 시 Local Space의 기준점을 잡는 것의 중요성.
  3. Transform: 행렬을 직접 다루지 않아도 SpriteBatch가 내부적으로 SRT 변환을 수행해준다는 점.

 

단순히 그림을 그리는 것을 넘어, 이제 그림을 '제어'할 수 있게 되었습니다. 다음 단계인 [텍스트 렌더링]에서는 비트맵 폰트를 이용해 UI를 구성하는 방법을 알아보겠습니다.