Event & EventQueue 리팩토링

2023. 11. 10. 17:26뚝딱뚝딱 만들기 Devlog/게임엔진 (Ramensoup)

라면스프 엔진이 이제 어느정도 엔진적인 기능(ECS, 씬 하이아키, 라이팅 등)이 필요한 때가 다가왔습니다.

앞으로 작업을 잘 하기 위해서, 그리고 C++ 개념을 익힐 겸 지금까지의 코드를 차근차근 리팩토링 하려 합니다.

첫 번째 타깃은 항상 마음 한켠에 남아있던 EventQueue입니다.

우선 EventQueue를 구현하면서 꼭 지키고싶었던건 아래와 같습니다.

  1. Event는 최대한 가볍게 가져가자 (메모리가 적게)
  2. EventQueue에 큐잉되는 이벤트들은 연속된 메모리공간에 놓자.
  3. 이를 위해서 vtable을 이용하지 않고 EventType을 이용한 다형성을 구현하자.

기존 구조

우선 Event를 처리하는 과정은 다음과 같습니다.

//WindowsWindow::InitCallbacks() 중
glfwSetCursorPosCallback(m_WindowHandle, [](GLFWwindow* window, double xPos, double yPos)
    {
        WindowData& data = *(WindowData*)glfwGetWindowUserPointer(window);
        Application::Get().QueueEvent(MouseMoveEvent((float)xPos, (float)yPos));
    });

//Application.h 중
template <typename T>
void QueueEvent(T&& e) { m_EventQueue.Push(e); }

우선 윈도우에서 이벤트가 일어날 때마다, Application의 QueueEvent를 호출하고,

QueueEvent는 EventQueue에 받은 이벤트를 포워딩 해줍니다.

이렇게 Queue에 들어간 이벤트는 Application::HandleEvents가 호출될 때 실행됩니다.

//Application.cpp 중
void Application::Run()
{
    m_IsRunning = true;
    while (m_IsRunning)
    {
           //...
        m_Window->OnUpdate();
        HandleEvents();
        //...
    }
}
void Application::HandleEvents()
{
    m_EventQueue.Flush(m_LayerStack);
}

 

이 부분이 문제였는데, EventQueue가 LayerStack에 의존함과 더불어,

Application은 이벤트 핸들링 과정에 대해 아무런 조작도 할 수 없습니다.

이런 말도안되는 구조는 굉장히 많은 비효율성을 낳았습니다.

 

어떤 참사가 일어났는지 보고싶다면 아래 내용을 펼쳐보시면 됩니다. (보기 힘들거에요)

더보기

Application이 가장 먼저 받아야 할 이벤트 (WindowClose와 WindowResize)를 처리하기 위해서

OverlayHandlers라는 map을 EventQueue가 가지게 해,

이벤트큐는 이 OverlayHandlers를 순회하고나서 LayerStack을 순회했습니다.

//EventQueue.h
template <typename T>
void HandleEvent(Event* e, LayerStack& layerStack)
{
    bool handled = false;
    if (m_OverlayHandlers.find(T::GetStaticType()) != m_OverlayHandlers.end())
    {
        handled = (std::any_cast<EventHandler<T>>(m_OverlayHandlers[T::GetStaticType()]))(*(T*)e);
    }
    if (!handled)
        layerStack.HandleEvent<T>(*(T*)e);
}

그래서 이벤트 하나를 처리하기 위해 이런 괴상한 코드가 등장했습니다.

이제보니 LayerStack에도 HandleEvent가 있었네요.

 

이벤트를 하나씩 pop해서 위 HandleEvent를 실행하는 코드도 가관입니다.

이세상 존재하는 모든 event type에 대해 코드를 작성해줘야합니다. (새 이벤트 타입을 추가할 때마다 건드려줘야함)

    //EventQueue.h
    #define DISPATCH(EventT)    case EventType::##EventT: \
                                HandleEvent<##EventT##Event>((##EventT##Event*)ptr, layerStack); \
                                ptr = (char*)ptr + PaddedSizeof<##EventT##Event>(); \
                                break

    void EventQueue::Flush(LayerStack& layerStack)
    {
        void* ptr = m_BufferBase;
        while (ptr < m_BufferPtr)
        {
            EventType eventType = *(EventType*)ptr;
            ptr = (char*)ptr + sizeof(EventType*);

            switch (eventType)
            {
            DISPATCH(KeyPress);
            //... 등 이 세상에 존재하는 모든 Event 종류
            DISPATCH(MouseMove);
            DISPATCH(MouseScroll);
            default:
                RS_CORE_ASSERT(false, "Unknown event!");
                break;
            }
        }
        /...
    }

이뿐만 아니라, 이악물고 가상함수를 안쓰면서 템플릿과 캐스팅을 (잘못) 쓰다보니

Layer에도 모든 event type에 대해 코드를 작성해줘야합니다. (새 이벤트 타입을 추가할 때마다 건드려줘야함)

//Layer.h 중
class Layer
{
public:
    Layer(const std::string& name = "Layer");
    //...
    virtual bool HandleEvent(const KeyReleaseEvent& e)            { return false; }
    virtual bool HandleEvent(const KeyPressEvent& e)            { return false; }
    //여기도 줄줄이..
    virtual bool HandleEvent(const MouseScrollEvent& e)            { return false; }
    //...
}

 

개선 결과

이 모든 원흉을 잡아내기 위해 EventQueue부터 변경했습니다.

Flush같은 기능은 Application이 수행하도록하고, EventQueue는 정말 push/pop만 지원하는 front와 rear를 가진 큐로 디자인했습니다.

//EventQueue.h
class EventQueue
{
public:
    EventQueue();
    ~EventQueue();

    inline bool IsEmpty() const { return m_FrontPtr >= m_RearPtr; }
    Event& Pop();
    void Clear();

    template <typename T>
    void Push(T&& e)
    //...
};

//Application.h
void Application::FlushEvents()
{
    while (!m_EventQueue.IsEmpty())
    {
        Event& e = m_EventQueue.Pop();
        bool handled = false;
        HandleEvent(e);  //Application handles first
        for (auto iter = m_LayerStack.cend(); iter != m_LayerStack.cbegin(); )
        {
            iter--;
            handled = (*iter)->HandleEvent(e);
            if (handled)
                break;
        }
    }
    m_EventQueue.Clear();
}

이제 이벤트 큐는 레이어스택을 몰라도 되고, 진짜 큐처럼 동작하게 됐습니다!

 

다음은 템플릿과 매크로로 처리하던 Event를 건드려줄 시간입니다.

기존 Event는 아무 데이터가 없는 클래스고, EventBase<Type, Category>가 Event를 상속받으며, 각각의 이벤트는 EventBase를 상속받는 방식입니다.

여기는 템플릿을 이용해 필요 메모리도 줄였고, virtual 함수들도 없으니 변화가 필요 없어 보였습니다.

대신 이제 Event가 자신의 Type을 알 수 있게 Event에 EventType 멤버변수를 추가해줬습니다.

struct Event
{
//...
protected:
    EventType Type; //새로 추가된 Type 변수
}

template<EventType type, EventCategory categoryFlags>
struct EventBase : public Event
{
    EventBase() { Type = type; } //Event::Type을 템플릿 상수를 이용해 초기화
    constexpr static EventType GetStaticType() { return type; }
    //... 기타 컴파일 타임 함수들 
    constexpr EventCategory GetCategory() const  { return categoryFlags; }
};

struct KeyPressEvent : public EventBase<EventType::KeyPress, EventCategory::Keyboard>
{
    int KeyCode;
    KeyPressEvent(int keyCode) : KeyCode(keyCode){}
};

이렇게 세팅해두면, 다형성 비슷한 것을 Event::Type과 EventBase::GetStaticType()을 이용해 구현할 수 있습니다.

그러면 아래같은 Dispatch 함수를 만들 수 있습니다.

//Event 클래스 내 
//...
//T : Event, U : Handling class
template<typename T, typename U>
using EventHandler = bool (U::*)(const T&);

//T : Event, U : Handling class
//e : the event, handler : handling function, instance : the handling object (this)
template<typename T, typename U>
static bool Dispatch(const Event& e, EventHandler<T, U> handler, U* instance)
{
    if (e.Type == T::GetStaticType())
        return (instance->*handler)((const T&)e);
    return false;
}

//Application.cpp 중
bool Application::HandleEvent(const Event& e)
{
    bool handled = false;
    handled = Event::Dispatch(e, &Application::OnWindowCloseEvent, this);
    handled = Event::Dispatch(e, &Application::OnWindowResizeEvent, this);
    return handled;
}
bool Application::OnWindowCloseEvent(const WindowCloseEvent& event)
{
    Close();
    return true;
}

 

찬찬히 설명해보면,

EventHandler는 클래스 멤버 함수를 함수 포인터로 담기위한 typedef입니다.

만약 Application::HandleEvent(WindowCloseEvent&)를 이벤트 처리함수로 담고싶다면,

T = WindowCloseEvent, U = Application이 되겠죠.

 

아래 Dispatch함수는 Event::Type을 T::GetStaticType()과 비교해, 일치할 경우 이벤트를 T로 캐스팅해서 handler를 instance (대부분의 경우 U의 this입니다) 에 대해 호출해줍니다.

T와 U가 자동으로 추론되기 때문에 아래쪽 사용 부분을 보면 정말 깔끔합니다.

 

레퍼런스로 참고하며 개발하던 Hazel 엔진에선 std::bind 등을 이용해서 비슷한 기능을 구현했는데,

훨씬 함수 파라미터가 짧아질 뿐만 아니라 bind 대신 함수 포인터를 이용했기 때문에 성능에서도 이점이 있을겁니다.

 

기존 방식과 비교해서도 여기저기 있던 의존성과 전처리기들을 빼서 훨씬 API가 깔끔해졌을 뿐만 아니라,

원래는 EventType을 switch문으로 비교해 각각의 Layer에 있는 각 이벤트처리 함수를 호출해줬는데,

이젠 각 Layer가 이벤트를 Dispatch해야한다는 단점은 있지만 모든 이벤트타입에 대해 switch문을 돌지 않아도 되고,

필요없는 빈 이벤트 처리 가상 함수를 주렁주렁 달고다닐 필요도 없어졌습니다.

 

빠르고 깔끔한 이벤트 큐를 위해 되게 고민 많이하고 실패도 많이했는데,

결국 잘 만들어진 것 같아 기분이 좋네요 ㅎㅎ

반응형