UV 맵핑을 이용한 2D 스프라이트 애니메이션 시스템 설계 및 구현
이 강의의 주제는 하나의 텍스처 이미지(Sheet)에서 개별 Sprite 프레임을 잘라내어 시간에 따라 재생하는 Sprite 기반 애니메이션 시스템을 C++과 DirectX로 직접 구현하는 것이다.
이를 위해 Keyframe, Animation, Animator 클래스와 함께 UV 보정 방식으로 텍스처 좌표를 다루는 방법을 학습하며, CPU-Shader-GPU까지 연결되는 전체 흐름을 구축한다.
CPU 측의 Animator는 어떤 Keyframe을 재생할지를 판단하고 있지만, 실제 출력은 GPU의 Shader에서 처리됩니다.
따라서 CPU에서 Keyframe의 Offset, Size, 전체 Texture 크기 등을 GPU로 전달해야 Shader가 UV 좌표를 보정해 정확한 Sprite 영역을 출력할 수 있습니다.
CPU에서 Shader로 데이터를 넘기기 위해 DirectX에서는 ConstantBuffer를 사용합니다.
애니메이션 정보를 담는 AnimationData 구조체를 생성하고, ConstantBuffer에 담아 GPU에 전달함으로써 Shader에서 사용할 수 있게 합니다.
애니메이션은 Sprite 시트의 특정 영역만 사용해야 하므로, Shader에서는 기본 UV 좌표를 조정하여 해당 Sprite 영역만 표시해야 합니다.
이 작업은 VertexShader 단계에서 수행됩니다.
| 용어 | 설명 |
|---|---|
| Sprite Sheet | 여러 프레임을 포함하는 단일 이미지 파일 |
| UV 좌표 | 텍스처 좌표계로, 0~1 범위 내에서 특정 영역 지정 |
| Keyframe | Sprite의 잘라낼 위치/크기/시간 정보 |
| Animation | Keyframe들의 집합 + Texture 정보 |
| Animator | 현재 실행 중인 Animation을 갱신하고 관리하는 컴포넌트 |
| ConstantBuffer | CPU → GPU로 데이터를 전송하는 구조체 버퍼 |
| TextureSize | 텍스처 이미지 전체 크기 |
| Loop | 애니메이션이 끝나면 다시 처음으로 반복할지 여부 |
ConstantBuffer | CPU에서 GPU로 데이터를 넘길 때 사용하는 버퍼 |
AnimationData | Sprite의 위치(offset), 크기(size), 텍스처 크기(textureSize)를 담은 구조체 |
PushAnimationData() | AnimationData를 ConstantBuffer에 복사하는 함수 |
cbuffer | HLSL에서 ConstantBuffer를 선언하는 구문 |
register(b2) | HLSL 상수 버퍼 슬롯 번호 설정 |
UV 좌표 | 텍스처에서의 위치를 나타내는 0~1 사이의 비율 값 |
useAnimation | 현재 애니메이션을 사용할지 여부를 float 값(1.0 / 0.0)으로 전달 |
struct Keyframe
{
Vec2 offset = Vec2{ 0.f, 0.f }; // 텍스처 시작 위치
Vec2 size = Vec2{ 0.f, 0.f }; // 잘라낼 Sprite의 크기
float time = 0.f; // 이 프레임의 유지 시간
};
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 정보 접근class Texture {
public:
Vec2 GetSize() { return size; }
private:
Vec2 size;
};
void Texture::Create(const wstring& path)
{
// 이미지 로드 후 텍스처 크기 설정
size.x = md.width;
size.y = md.height;
}
TexMetadata의 width, height를 Vec2에 저장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;
};
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 여부에 따라 다시 처음으로 돌아가거나 마지막 프레임을 유지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);
}
auto animator = make_shared<Animator>();
cat->AddComponent(animator);
auto anim = RESOURCES->Get<Animation>(L"SnakeAnim");
animator->SetAnimation(anim);
RenderHelper.h – 애니메이션 데이터 구조체 정의struct AnimationData
{
Vec2 spriteOffset; // Sprite의 시작 위치
Vec2 spriteSize; // Sprite의 크기
Vec2 textureSize; // 전체 텍스처 크기
float useAnimation; // 애니메이션 적용 여부
float padding; // GPU 16바이트 정렬을 위한 여유 공간
};
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();
RenderManager::PushAnimationData() – GPU에 데이터 복사void RenderManager::PushAnimationData()
{
_animationBuffer->CopyData(_animationData);
}
_animationData에 정보를 채운 후, 이 데이터를 ConstantBuffer에 복사하여 GPU로 전송합니다.GameObject에서 Animator 가져오기shared_ptr<Animator> GameObject::GetAnimator()
{
shared_ptr<Component> component = GetFixedComponent(ComponentType::Animator);
return static_pointer_cast<Animator>(component);
}
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());
}
Default.hlsl 수정cbuffer AnimationData : register(b2)
{
float2 spriteOffset;
float2 spriteSize;
float2 textureSize;
float useAnimation;
};
register(b2)는 이 ConstantBuffer가 슬롯 b2에 연결됨을 의미합니다.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;
}
spriteSize / textureSize: 사용할 영역의 비율spriteOffset / textureSize: 시작 지점 보정class CameraMove : public MonoBehaviour
{
public:
virtual void Update() override;
};
void CameraMove::Update()
{
auto pos = GetTransform()->GetPosition();
pos.x += 0.001f;
GetTransform()->SetPosition(pos);
}
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 이동 컴포넌트를 활용하면 애니메이션 외에 연출 효과도 줄 수 있습니다.