수업


📘 주제

UV 맵핑을 이용한 2D 스프라이트 애니메이션 시스템 설계 및 구현

이 강의의 주제는 하나의 텍스처 이미지(Sheet)에서 개별 Sprite 프레임을 잘라내어 시간에 따라 재생하는 Sprite 기반 애니메이션 시스템을 C++과 DirectX로 직접 구현하는 것이다.

이를 위해 Keyframe, Animation, Animator 클래스와 함께 UV 보정 방식으로 텍스처 좌표를 다루는 방법을 학습하며, CPU-Shader-GPU까지 연결되는 전체 흐름을 구축한다.


📗 개념

✅ Sprite Animation

  • 하나의 텍스처 이미지(Sheet)에 여러 프레임을 일정 규칙으로 배치하고,
    시간에 따라 순차적으로 잘라서 출력함으로써 애니메이션을 구현하는 방식이다.
  • 게임에서 흔히 사용하는 Sprite Sheet 기반 2D 애니메이션을 의미하며, Unity에서는 Sprite Editor, Unreal에서는 Flipbook으로 구현됨.

✅ UV Mapping

  • UV는 텍스처의 좌표계를 나타낸다. 일반적으로 0~1 범위 내의 좌표로 표현됨.
  • 전체 텍스처의 특정 위치(offset)와 크기(size)를 비율로 변환하여, 원하는 이미지 영역만 출력할 수 있다.

✅ Keyframe

  • Sprite 이미지에서 자를 영역(offset, size)과 프레임 재생 시간(time) 을 묶은 구조체
  • Animation은 여러 Keyframe들의 집합이다.

✅ Animation

  • Keyframe과 텍스처 정보를 담는 리소스
  • Sprite 시트를 기반으로 프레임 정보를 순차적으로 재생할 수 있도록 데이터를 관리

✅ Animator

  • 현재 애니메이션 상태를 관리하고, 시간 흐름에 따라 현재 보여줄 Keyframe을 갱신하는 컴포넌트
  • Animation 리소스를 받아 시간 누적 기반으로 키프레임을 전환하며 재생 로직을 수행

✅ 애니메이션 GPU 연동의 필요성

CPU 측의 Animator는 어떤 Keyframe을 재생할지를 판단하고 있지만, 실제 출력은 GPU의 Shader에서 처리됩니다.
따라서 CPU에서 Keyframe의 Offset, Size, 전체 Texture 크기 등을 GPU로 전달해야 Shader가 UV 좌표를 보정해 정확한 Sprite 영역을 출력할 수 있습니다.

✅ ConstantBuffer를 통한 데이터 전달

CPU에서 Shader로 데이터를 넘기기 위해 DirectX에서는 ConstantBuffer를 사용합니다.
애니메이션 정보를 담는 AnimationData 구조체를 생성하고, ConstantBuffer에 담아 GPU에 전달함으로써 Shader에서 사용할 수 있게 합니다.

✅ UV 맵핑 보정

애니메이션은 Sprite 시트의 특정 영역만 사용해야 하므로, Shader에서는 기본 UV 좌표를 조정하여 해당 Sprite 영역만 표시해야 합니다.
이 작업은 VertexShader 단계에서 수행됩니다.


📘 용어 정리

용어설명
Sprite Sheet여러 프레임을 포함하는 단일 이미지 파일
UV 좌표텍스처 좌표계로, 0~1 범위 내에서 특정 영역 지정
KeyframeSprite의 잘라낼 위치/크기/시간 정보
AnimationKeyframe들의 집합 + Texture 정보
Animator현재 실행 중인 Animation을 갱신하고 관리하는 컴포넌트
ConstantBufferCPU → GPU로 데이터를 전송하는 구조체 버퍼
TextureSize텍스처 이미지 전체 크기
Loop애니메이션이 끝나면 다시 처음으로 반복할지 여부
ConstantBufferCPU에서 GPU로 데이터를 넘길 때 사용하는 버퍼
AnimationDataSprite의 위치(offset), 크기(size), 텍스처 크기(textureSize)를 담은 구조체
PushAnimationData()AnimationData를 ConstantBuffer에 복사하는 함수
cbufferHLSL에서 ConstantBuffer를 선언하는 구문
register(b2)HLSL 상수 버퍼 슬롯 번호 설정
UV 좌표텍스처에서의 위치를 나타내는 0~1 사이의 비율 값
useAnimation현재 애니메이션을 사용할지 여부를 float 값(1.0 / 0.0)으로 전달

📙 코드 분석


🔷 Keyframe 구조체

struct Keyframe
{
	Vec2 offset = Vec2{ 0.f, 0.f };  // 텍스처 시작 위치
	Vec2 size = Vec2{ 0.f, 0.f };    // 잘라낼 Sprite의 크기
	float time = 0.f;                // 이 프레임의 유지 시간
};
  • 하나의 프레임이 차지하는 정보
  • offset: Sprite 시트 기준 좌측 상단 위치
  • size: 잘라낼 Sprite의 크기
  • time: 이 프레임이 화면에 유지될 시간 (초 단위)

🔷 Animation 클래스

class Animation : public ResourceBase
{
public:
	void SetLoop(bool loop);
	bool IsLoop();

	void SetTexture(shared_ptr<Texture> texture);
	shared_ptr<Texture> GetTexture();
	Vec2 GetTextureSize();

	const Keyframe& GetKeyframe(int32 index);
	int32 GetKeyframeCount();
	void AddKeyframe(const Keyframe& keyframe);

private:
	bool bLoop = false;
	shared_ptr<Texture> m_pTexture;
	vector<Keyframe> m_vKeyframes;
};

주요 함수 해설:

  • SetLoop, IsLoop: 반복 재생 여부를 설정하고 확인
  • SetTexture, GetTexture: 사용할 Sprite 텍스처 설정 및 반환
  • GetTextureSize: 텍스처 전체 크기를 반환 (UV 보정을 위한 비율 계산에 사용)
  • AddKeyframe: Keyframe을 Animation에 추가
  • GetKeyframe, GetKeyframeCount: 현재 Animation이 가진 Keyframe 정보 접근

🔷 Texture 클래스와 크기 설정

class Texture {
public:
	Vec2 GetSize() { return size; }

private:
	Vec2 size;
};

void Texture::Create(const wstring& path)
{
	// 이미지 로드 후 텍스처 크기 설정
	size.x = md.width;
	size.y = md.height;
}
  • 텍스처에서 Sprite를 잘라내기 위해 전체 크기를 기준으로 Sprite 크기와 오프셋 비율을 계산해야 한다.
  • 텍스처가 생성될 때 TexMetadata의 width, height를 Vec2에 저장

🔷 Animator 클래스

class Animator : public Component
{
public:
	void Init();
	void Update();

	shared_ptr<Animation> GetCurrentAnimation();
	const Keyframe& GetCurrentKeyframe();
	void SetAnimation(shared_ptr<Animation> animation);

private:
	float m_fSumTime = 0.0f;
	int32 m_nCurrentKeyframeIndex = 0;
	shared_ptr<Animation> m_pCurrentAnimation;
};

Update 함수 구현

void Animator::Update()
{
	shared_ptr<Animation> animation = GetCurrentAnimation();
	if (!animation) return;

	const Keyframe& keyframe = animation->GetKeyframe(m_nCurrentKeyframeIndex);
	m_fSumTime += TIME->GetDeltaTime();

	if (m_fSumTime >= keyframe.time)
	{
		m_nCurrentKeyframeIndex++;
		int32 totalCount = animation->GetKeyframeCount();

		if (m_nCurrentKeyframeIndex >= totalCount)
		{
			if (animation->IsLoop())
				m_nCurrentKeyframeIndex = 0;
			else
				m_nCurrentKeyframeIndex = totalCount - 1;
		}
		m_fSumTime = 0.0f;
	}
}

작동 원리:

  • TIME->GetDeltaTime()으로 시간 누적
  • 누적 시간이 현재 프레임의 time보다 크면 다음 프레임으로 전환
  • 마지막 프레임까지 재생했다면 loop 여부에 따라 다시 처음으로 돌아가거나 마지막 프레임을 유지

🔷 ResourceManager에서 Texture & Animation 등록

void ResourceManager::CreateDefaultTexture()
{
	auto texture = make_shared<Texture>(_device);
	texture->SetName(L"Snake");
	texture->Create(L"Snake.bmp");
	Add(texture->GetName(), texture);
}
void ResourceManager::CreateDefaultAnimation()
{
	auto animation = make_shared<Animation>();
	animation->SetName(L"SnakeAnim");
	animation->SetTexture(Get<Texture>(L"Snake"));
	animation->SetLoop(true);

	animation->AddKeyframe({ {0.f, 0.f}, {100.f, 100.f}, 0.1f });
	animation->AddKeyframe({ {100.f, 0.f}, {100.f, 100.f}, 0.1f });
	animation->AddKeyframe({ {200.f, 0.f}, {100.f, 100.f}, 0.1f });
	animation->AddKeyframe({ {300.f, 0.f}, {100.f, 100.f}, 0.1f });

	Add(animation->GetName(), animation);
}

구성 해설:

  • Snake 텍스처 이미지에서 100x100 크기의 Sprite를 0.1초 간격으로 4프레임 재생
  • Sprite의 위치는 x좌표 기준으로 100씩 이동 (가로 시트 기준)

🔷 SceneManager에서 애니메이션 적용

auto animator = make_shared<Animator>();
cat->AddComponent(animator);

auto anim = RESOURCES->Get<Animation>(L"SnakeAnim");
animator->SetAnimation(anim);
  • GameObject에 Animator 컴포넌트를 추가하고
  • 리소스 매니저에 등록된 Animation을 가져와 재생하도록 설정

🔷 RenderHelper.h – 애니메이션 데이터 구조체 정의

struct AnimationData
{
    Vec2 spriteOffset;     // Sprite의 시작 위치
    Vec2 spriteSize;       // Sprite의 크기
    Vec2 textureSize;      // 전체 텍스처 크기
    float useAnimation;    // 애니메이션 적용 여부
    float padding;         // GPU 16바이트 정렬을 위한 여유 공간
};
  • GPU의 ConstantBuffer는 16바이트 단위 정렬을 요구하기 때문에 float 1개 짜리 공간인 padding을 넣습니다.
  • useAnimation은 bool을 사용할 수 없기 때문에 float으로 처리합니다 (1.0f 또는 0.0f).

🔷 RenderManager.h – Animation 관련 멤버 추가

AnimationData _animationData;
shared_ptr<ConstantBuffer<AnimationData>> _animationBuffer;
  • _animationData: 현재 애니메이션 정보를 저장하는 구조체
  • _animationBuffer: 애니메이션 데이터를 GPU에 복사하기 위한 ConstantBuffer 객체

🔷 RenderManager::Init() – 애니메이션 버퍼 생성

_animationBuffer = make_shared<ConstantBuffer<AnimationData>>(_device, _deviceContext);
_animationBuffer->Create();
  • AnimationData를 GPU에 전달할 수 있는 ConstantBuffer를 생성하고 초기화합니다.

🔷 RenderManager::PushAnimationData() – GPU에 데이터 복사

void RenderManager::PushAnimationData()
{
    _animationBuffer->CopyData(_animationData);
}
  • CPU에서 _animationData에 정보를 채운 후, 이 데이터를 ConstantBuffer에 복사하여 GPU로 전송합니다.

🔷 GameObject에서 Animator 가져오기

shared_ptr<Animator> GameObject::GetAnimator()
{
    shared_ptr<Component> component = GetFixedComponent(ComponentType::Animator);
    return static_pointer_cast<Animator>(component);
}
  • GameObject에 붙어 있는 Animator 컴포넌트를 가져오는 함수입니다.

🔷 RenderManager::RenderObjects()` – 애니메이션 유무에 따른 처리

shared_ptr<Animator> animator = gameObject->GetAnimator();
if (animator)
{
    const Keyframe& keyframe = animator->GetCurrentKeyframe();
    _animationData.spriteOffset = keyframe.offset;
    _animationData.spriteSize = keyframe.size;
    _animationData.textureSize = animator->GetCurrentAnimation()->GetTextureSize();
    _animationData.useAnimation = 1.0f;
    PushAnimationData();

    _pipeline->SetConstantBuffer(2, SS_VertexShader, _animationBuffer);
    _pipeline->SetTexture(0, SS_PixelShader, animator->GetCurrentAnimation()->GetTexture());
}
else
{
    _animationData.spriteOffset = Vec2(0.f, 0.f);
    _animationData.spriteSize = Vec2(0.f, 0.f);
    _animationData.textureSize = Vec2(0.f, 0.f);
    _animationData.useAnimation = 0.0f;
    PushAnimationData();

    _pipeline->SetConstantBuffer(2, SS_VertexShader, _animationBuffer);
    _pipeline->SetTexture(0, SS_PixelShader, meshRenderer->GetTexture());
}
  • Animator가 있을 경우 현재 Keyframe의 데이터를 GPU에 넘기고 애니메이션을 출력합니다.
  • 없을 경우 값들을 0으로 초기화하여 애니메이션을 비활성화합니다.

🔷 Shader 연동 – Default.hlsl 수정

cbuffer AnimationData : register(b2)
{
    float2 spriteOffset;
    float2 spriteSize;
    float2 textureSize;
    float useAnimation;
};
  • ConstantBuffer는 Shader에 애니메이션 데이터를 넘겨주는 구조입니다.
  • register(b2)는 이 ConstantBuffer가 슬롯 b2에 연결됨을 의미합니다.

🔷 HLSL Vertex Shader – UV 보정 처리

VS_OUTPUT VS(VS_INPUT input)
{
    VS_OUTPUT output;
    
    float4 position = mul(input.position, matWorld);
    position = mul(position, matView);
    position = mul(position, matProjection);
    output.position = position;

    output.uv = input.uv;

    if (useAnimation == 1.0f)
    {
        output.uv *= spriteSize / textureSize;
        output.uv += spriteOffset / textureSize;
    }

    return output;
}
  • UV 좌표를 Sprite 시트의 특정 영역만 사용하도록 보정합니다.
  • spriteSize / textureSize: 사용할 영역의 비율
  • spriteOffset / textureSize: 시작 지점 보정

🔷 응용 – 카메라 이동을 통한 연출 효과

🎥 CameraMove 클래스

class CameraMove : public MonoBehaviour
{
public:
    virtual void Update() override;
};

void CameraMove::Update()
{
    auto pos = GetTransform()->GetPosition();
    pos.x += 0.001f;
    GetTransform()->SetPosition(pos);
}

📌 SceneManager에서 카메라에 적용

camera->AddComponent(make_shared<CameraMove>());
  • 카메라가 오른쪽으로 이동함으로써 정적인 애니메이션도 움직이는 효과를 줄 수 있습니다.

🔷 핵심

  • Animation은 Sprite 시트 기반의 프레임 정보(Keyframe)를 갖고 있음

  • Animator는 시간 흐름에 따라 어떤 프레임을 출력할지를 계산하는 Component

  • Keyframe은 각 프레임마다 Sprite의 위치/크기/재생 시간을 지정

  • ResourceManager는 Texture와 Animation을 등록하고 관리

  • SceneManager는 GameObject에 Animator를 부착하여 애니메이션 실행을 가능하게 함

  • 애니메이션 데이터를 GPU에 전달하기 위해 AnimationData 구조체와 ConstantBuffer를 구성하고 이를 Shader에 연동합니다.

  • Shader의 Vertex Shader 단계에서 UV 좌표를 계산하여 Sprite Sheet 내의 특정 영역만 출력하도록 보정합니다.

  • GameObject의 Animator 유무에 따라 애니메이션 정보 전달 여부를 결정하고, 없는 경우 UV 보정을 생략합니다.

  • 16바이트 정렬(padding)을 맞추는 것은 GPU에서 데이터를 정확히 읽기 위한 필수 조건입니다.

  • Camera 이동 컴포넌트를 활용하면 애니메이션 외에 연출 효과도 줄 수 있습니다.


profile
李家네_공부방

0개의 댓글