DirectX

87_Button 실습

devRiripong 2024. 4. 1. 20:42
반응형

버튼을 클릭했을 때 눌리는 걸 어떻게 할지 고민한다.

 

유니티에서 UI를 사용할 때 EventSystem을 실수로 삭제하면 클릭해도 먹통이 된다.

EventSystem 오브젝트에 붙어있는 Event System, Standalone Input Module 이 아이가 실질적으로 UI를 클릭할 수 있게 만들어준다고 보면 된다.

 

 collision box를 만들어 레이캐스팅으로 레이저를 쏘는 것도 방법이고,

버튼의 영역을 체크해서 하는게 효율적이긴하다.

Screen 좌표로 영역에 포함이 되었는지 체크하는 방식으로 해본다.

 

포폴 만들 때 필요한 기능은 raycasting, 충돌, 애니메이션, mesh 로드해서 material을 입히는 거 등이 있으면 게임이 만들어진다. 그걸 아는 상태에서 유니티, 언리얼을 공부하면 어자피 그것만 있으면 게임을 만들 수 있는지 아니까 유니티에서 raycasting을 어떻게 하는지 알아보고, 애니메이션 조절을 어떻게 하는지 알아보고, 물체 같은거 로드해서 메모리에다가 리소슬 로드하는 거 어떻게 하는지 알아보고, 툴은 편하게 제공할테니까 그렇게 생각하면 뭘 어느 단계에서 시작해서 뭘 만들어야 되는지가 굉장히 명확해진다. 상용 엔진에 겁을 먹을 필요가 없다. 각 한달 컷으로 유니티한달, 언리얼 한달 한다 생각하면 된다.

 

이어서 Button 클래스를 만들어서 한다. 피킹과 관련된 코드를 정리하기 위해서기도 하고, 매번 만들 때 마다 Demo에 Mesh를 넣고 하는게 복잡하다 보니까 Terrain 만들 때도 그 부분을 묶어서 관리할 수 있도록 Terrain 이란 클래스를 만들었던 것 처럼 비슷한 이유로 일단은 Button이란 개념이 등장 해야 한다.

이걸 더 효과적으로 만드려면 사용엔진을 참고해서 UI를 어떻게 관리하는지 보고 이미지, 버튼, 온갖 토글 버튼 같은 잡동사니들을 계층 구조로 관리할 수 있게 하는게 최종적인 목표라 할 수 있다. 하지만 거기까지 가긴 너무 머니 여기선 그렇게 까진 안한다.

 

1. Component를 상속한 Button 클래스 만들기

Engine에 06. UI & Effect 필터를 만든다.

그 안에 Component를 상속한 Button클래스를 생성한다.

UI는 포폴 만들 때 너무 공을 안드리는게 좋다. 인게임 framework 컨텐츠랑 나머지 설계랑 관련된 부분, 매니져가 핵심이지 button이나 UI, Effect는 상대적으로 중요도가 떨어진다. 일반적으로는 코딩 난이도나 기술적인 걸 먼저 본다. 상용 엔진에서 UI는 다 해준다.

 

1) Component클래스에서 ComponentType에 Button을 추가하고, GameObject에서 GetButton 함수 정의하기

Component.h에 가서

enum class ComponentType : uint8
{
	Transform,
	MeshRenderer,
	Camera,
	ModelRenderer,
	Animator,
	Light,
	Collider,
	Terrain,
	Button, 
	// ...
	Script, 

	End, 
};

Button을 추가하고,

 

GameObject.h에 가서

class Button; 

전방 선언을 하고,

	shared_ptr<Button> GetButton(); 

GameObject.cpp에

#include "Button.h"
shared_ptr<Button> GameObject::GetButton()
{
	shared_ptr<Component> component = GetFixedComponent(ComponentType::Button);
	return static_pointer_cast<Button>(component);
}

GetButton을 정의한다. 

 

2) Button 클래스 정의하기

Button 클래스로 가서

#pragma once
#include "Component.h"
class Button :  public Component
{
	using Super = Component; 

public: 
	Button(); 
	virtual ~Button(); 

	bool Picked(POINT screenPos); 

	void Create(Vec2 screenPos, Vec2 size, shared_ptr<class Material> material); 

private: 
	std::function<void(void)> _onClicked; 
	RECT _rect; 

};
#include "pch.h"
#include "Button.h"
#include "MeshRenderer.h"
#include "Material.h"

Button::Button() : Super(ComponentType::Button)
{
}

Button::~Button()
{
}

bool Button::Picked(POINT screenPos)
{
	return ::PtInRect(&_rect, screenPos); 
}

void Button::Create(Vec2 screenPos, Vec2 size, shared_ptr<class Material> material)
{
	auto go = _gameObject.lock(); 

	float height = GRAPHICS->GetViewport().GetHeight(); 
	float width = GRAPHICS->GetViewport().GetWidth(); 

	// 화면의 중앙을 원점으로 하는 새로운 좌표계에서의 위치
	float x = screenPos.x - width / 2; 
	float y = height / 2 - screenPos.y; 
	Vec3 position = Vec3(x, y, 0);

	go->GetOrAddTransform()->SetPosition(position); 
	go->GetOrAddTransform()->SetScale(Vec3(size.x, size.y, 1)); 

	go->SetLayerIndex(Layer_UI); 

	if (go->GetMeshRenderer() == nullptr)
		go->AddComponent(make_shared<MeshRenderer>()); 

	go->GetMeshRenderer()->SetMaterial(material); 

	auto mesh = RESOURCES->Get<Mesh>(L"Quad"); 
	go->GetMeshRenderer()->SetMesh(mesh); 
	go->GetMeshRenderer()->SetPass(0); 

	// Picking
	_rect.left = screenPos.x - size.x / 2;
	_rect.right = screenPos.x + size.x / 2; 
	_rect.top = screenPos.y - size.y / 2; 
	_rect.bottom = screenPos.y + size.y / 2; 
}

이렇게 하고,

 

3) Button 클래스에 AddOnClickedEvent와 InvokeOnClikced 함수 추가하기

_OnClicked로 추가한 콜백함수를 일단 호출할 예정이다.

유니티에서도 버튼을 눌렀을 때 어떤 식으로 콜백을 호출하게끔 연동을 해주는 식으로 되어 있다.

Button 클래스에 2개의 함수를 추가한다. 

	void AddOnClickedEvent(std::function<void(void)> func); 
	void InvokeOnClicked(); // 버튼이 눌렸으니까 등록된 함수가 있으면 호출하라. C#으로 치면 delegate
void Button::AddOnClickedEvent(std::function<void(void)> func)
{
	_onClicked = func; 
}

void Button::InvokeOnClicked()
{
	if (_onClicked)
		_onClicked(); 
}

 

4) 람다 캡쳐 사용 시 주의사항

std::function<void(void)> _onClicked;에 콜백함수를 대입해야 한다.

콜백함수 쓸 때 조심해야 할 것이 있는데

만약 연결할 함수가 stastic이나 전역함수면 별다른 문제가 없다.

하지만 lambda캡쳐를 통해 함수를 넣어주면 여러 문제가 생길 수 있다.

[=]()
{
// TODO
}

만약 TODO 안에 포인터 타입을 복사해서 저장하고 있다고 하면 포인터 특성상 주소가 나중에 메모리가 해제되면 주소가 날아갈 수 있다. 그 주소를 기억하다 사용하면 난리가 날 수 있다.

스마트 포인터면 딱히 상관이 없지 않을까? 누구라도 한명이라도 기억하지 않을때 해제가 되는 거다 보니까 여기서 캡쳐하는 바람에 영영해제가 안되고 묶여 있을 수 있다.

C#에서도 같은 문제가 있다. C#은 스마트 포인터가 자동 사용되는 느낌이라 조심해야 될 필요가 있다.

이게 하나의 functor와 개념이 비슷하다. 포인터 같은 거 들고 있다면 조심해야 한다.

그걸 감안해서 는 거다.

 

람다 캡쳐 사용할 때 스마트 포인터를 사용할 거면 스마트 포인터의 참조값이 캡쳐되지 않게 조심해야 한다. 참조값으로 넘기면 레퍼런스가 늘어나지 않는 상태로 넘기니까 이제까지 ref count 관리가 다 허사가 될 수 있다. 그런 부분이 C++에서 까다롭다.

 

Engine을 빌드한다.

 

2. Scene클래스에 Button의 Picked(screenPt)가 참이면 InvokeOnCliciked가 호출되는 PickUI 함수를 정의하고 Scene::Update에서 호출하기

이렇게 Button 클래스를 만들었가 가정을 하고, Picking 함수를 어디에 넣어줘야 할까가 고민이긴 한데

Scene에 가서 보면

Scene::Pick에 일반적인 Picking 코드가 들어가 있다.

여기에 culling 코드를 넣어준다.

		if(camera->IsCulled(gameObject->GetLayerIndex()))
			continue; 

카메라가 관련이 없는 애면 스킵하게 예외코드를 넣어 줬다.

UI같은 경우는 Picking이어도 레이저 쏘는 피킹이 아니라 Rect Transform만 체크하는 용도로 일단은 바라보고 있으니까 유니티처럼 EventSystem이라는 컴포넌트를 만들어서 거기에 UI 피킹과 과련된 코드를 넣어주는 것도 방법이다. 그렇게 까진 하기 싶다고 하면, Scene.h에

	void PickUI(); 

를 넣어주고, 어딘가에서 호출할 수 있게 만들어주도록 한다.

 

어딘가가 아니라 UI가 배치 되었으면 항상 누르는게 일반적일테니 Scene::Update에서

void Scene::Update()
{
	unordered_set<shared_ptr<GameObject>> objects = _objects;

	for (shared_ptr<GameObject> object : objects)
	{
		object->Update();
	}

	PickUI(); 
}

이렇게 호출되게 넣어준다.

 

Scene.cpp에

#include "Button.h"

을 추가하고

void Scene::PickUI()
{
	if (INPUT->GetButtonDown(KEY_TYPE::LBUTTON) == false)
		return; 

	if (GetUICamera() == nullptr)
		return;

	POINT screenPt = INPUT->GetMousePos(); 

	shared_ptr<Camera> camera = GetUICamera()->GetCamera(); 

	const auto gameObjects = GetObjects(); // &를 auto 뒤에 넣느냐 마느냐는 버튼을 눌렀을 때 물체가 제거가 되면 난리가 나니 복사를 해야 한다. 확실하게 제거를 안하겠다면 참조로 &을 넣어도 된다. . 

	for (auto& gameObject : gameObjects) 
	{
		if (gameObject->GetButton() == nullptr)
			continue; 

		if (gameObject->GetButton()->Picked(screenPt))
			gameObject->GetButton()->InvokeOnClicked(); 
	}
}

만약에 영역 안에 들어왔으면 InvokeOnClicked을 해줄 것이고,

void Button::InvokeOnClicked()
{
	if (_onClicked)
		_onClicked(); 
}

여기서 누군가 등록한게 있으면 요 아이를 호출해준다. 

 

Engine을 빌드한다.

 

3. ButtonDemo 클래스에서 클릭 이벤트에 람다 함수를 연결하고 Button 클릭을 테스트하기

1) ButtonDemo 클래스 만들기

OrthographicDemo클래스를 복붙하고, 이름을 ButtonDemo라고 한다.

Client/Game 필터에 넣는다.

코드를 ButtonDemo에 맞게 수정한다.

Main에 세팅한다.

Client를 빌드한다.

 

2) obj에 Button 컴포넌트를 넣고, 클릭을 하면 발생할 이벤트를 AddOnClickedEvent 함수의 인자로 람다식을 넣어 _onClicked에 연결하기

ButtonDemoi.cpp에

#include "Button.h"

를 추가하고

 

ButtonDemo::Init에서

Quad로 만들었던 첫번째 Mesh를 버튼으로 바꿔본다.

	// Mesh
	{
		auto obj = make_shared<GameObject>();
		obj->AddComponent(make_shared<Button>());

		obj->GetButton()->Create(Vec2(100, 100), Vec2(100, 100), RESOURCES->Get<Material>(L"Veigar")); 

		CUR_SCENE->Add(obj);
	}

여기서 버튼을 눌렀을 때 뭔가가 일어나게 하고 싶으면

		obj->GetButton()->AddOnClickedEvent([obj]() { CUR_SCENE->Remove(obj);  }); // 람다캡쳐에 메모리 이슈가 있을 수 있다는 걸 기억한다.

 

이렇게 람다식을 AddOnClickedEvent의 인자로 추가한다.

	// Mesh
	{
		auto obj = make_shared<GameObject>();
		obj->AddComponent(make_shared<Button>());

		obj->GetButton()->Create(Vec2(100, 100), Vec2(100, 100), RESOURCES->Get<Material>(L"Veigar")); 

		obj->GetButton()->AddOnClickEvent([obj]() { CUR_SCENE->Remove(obj);  }); // 람다캡쳐에 메모리 이슈가 있을 수 있다는 걸 기억한다. 

		CUR_SCENE->Add(obj);
	}

 

3) 람다식 보충 설명

람다가 이해가 잘 안되면 functor를 만들어 보면 된다.

 

함수 포인터는 함수의 로직을 지정할 수 있지만, 그 함수가 실행될 때 필요한 추가적인 데이터를 저장할 방법이 없었다.

class Functor
{
public: 

	int a; 

	// 함수 포인터 
};

Functor를 사용함으로써, 함수 포인터가 가질 수 없었던 상태를 클래스의 멤버 변수로 저장할 수 있게 되었다. 이를 통해 함수처럼 호출할 수 있는 객체 내에 데이터를 저장하고, 이 데이터에 기반하여 함수의 동작을 변경할 수 있다. 이러한 방식은 단순한 함수 호출을 넘어서, 상태를 내장한 더 복잡한 로직을 구현할 수 있게 해준다. 1+1이기 때문에 더 넓은 범위를 활용할 수 있었다.

 

int a 대신

shared_ptr<GameObject> obj;

이렇게도 저장할 수 있을 것이다.

Lamda에서 캡쳐한게 사실상 멤버 변수에다가 shared_ptr<GameObject> obj를 기입을 한 거랑 마찬가지다.

이걸 이용해서 추가적인 작업을 한다는 거여서 람다에서 캡쳐는 이 펑터에서 멤버 변수로 이 obj를 들고 있는 거랑 같은 얘기가 된다.

 

4) 테스트 하기

실행을 하면

이렇게 버튼이 뜨고 버튼을 클릭하면 사라진다.

 

버튼을 눌렀을 때

여기에 들어 온 것을 확인할 수 있다.

어떻게 여기까지 왔나 볼 수 있다.

_onClicked()가 실행되면 _onClicked에 넣어 놨던 람다 함수가 호출된다.

f11을 누르면

ButtonDemo::Init의

obj->GetButton()->AddOnClickedEvent([obj]() { CUR_SCENE->Remove(obj); });

이 코드가 실행 되는 것을 볼 수 있다.

 

함수를 등록했던 부분이랑, 함수를 호출했던 부분이 나눠지게 된 것이다.

나중에 이 함수가 호출이 되게끔 유도를 해준 것이다.

 

4. 맺음말

UI를 간단하게 만들어봤다.

중요한 건 Picking을 어떻게 할지랑

UI를 눌렀을 때 콜백함수를 지정하는 방식

두 가지가 핵심이라고 볼 수 있다.

움직이는 UI라면

Button::Create의

	// Picking
	_rect.left = screenPos.x - size.x / 2;
	_rect.right = screenPos.x + size.x / 2; 
	_rect.top = screenPos.y - size.y / 2; 
	_rect.bottom = screenPos.y + size.y / 2; 

이런 부분들을 갱신하는 코드를 적절하게 넣어주면 된다.

 

남은 건 그림자, 빌보드, 이펙트 같이 게임을 꾸미는 거, deferred rendering까지 하면 끝난다.

 

핫도그 먹기 대회에서 재료별로 먹어서 우승한 경우가 있는데 이것과 마찬가지로 deferred rendering은 한번에 다 그리는게 아니라 정보를 취합해서 그 정보를 이용해서 한번에 그리는게 특징이다. 조명이 여러개일 때 효과가 좋다.

 

UI코드가 이런저런 용도로 사용이 된다.

반응형