반응형
  • 티스토리 홈
  • 프로필사진
    묭묭.cpp
  • 방명록
  • 공지사항
  • 태그
  • 블로그 관리
  • 글 작성
묭묭.cpp
  • 프로필사진
    묭묭.cpp
    • 분류 전체보기 (103)
      • 데이터베이스 (2)
      • 포트폴리오 (25)
      • 윈도우 인터널즈 (20)
      • 네트워크 (4)
      • IOCP 게임서버 (11)
      • C (2)
      • 디스어셈블리 디버깅 (1)
      • WindowsAPI (11)
      • 학원 강의 정리 모음 (20)
      • 운영체제 (5)
  • 방문자 수
    • 전체:
    • 오늘:
    • 어제:
  • 최근 댓글
      등록된 댓글이 없습니다.
    • 최근 공지
        등록된 공지가 없습니다.
      # Home
      # 공지사항
      #
      # 태그
      # 검색결과
      # 방명록
      • 윈도우즈 API 게임 제작 준비 03 - Core 클래스, Timer 클래스
        2023년 09월 23일
        • 묭묭.cpp
        • 작성자
        • 2023.09.23.:53

        지난 포스팅까지 공부한 내용으로는 원래 윈도우 API 프로그램은 메시지 기반으로 작동하고 게임을 만들기에는 적합하지 않아 이것을 메시지 기반으로 작동하지 않게 하기 위해 PeekMessage 함수를 이용하여 메시지가 없을 때에도 동작하도록 만들었다.

         

        이번 포스팅에서는 메시지가 없을 때에 해야하는 작업을 하는 Core 클래스를 작업할 것이다.

        어소트락 Win32 API 무료강의의 7~12 강의 내용에 해당한다.

         

        먼저 Core 클래스를 만들기 전에 모든 Manager 클래스의 객체는 모든 프로그램에서 딱 1개만 존재해야 한다.

        이를 위해서는 싱글톤 패턴에 대해 이해하고 모든 매니저 클래스는 싱글톤 패턴으로 구현되어 있어야 한다.

        먼저 싱글톤 패턴의 구현에 대해서 알아보고 시작하겠다.

         

        1. 싱글톤 패턴

        싱글톤 패턴의 동작 원리는 다음과 같다.

        1. 객체를 생성할 때 객체가 없다면 생성하고 그것을 반환
        2. 객체가 이미 있다면 이미 존재하던 객체를 반환

        위의 설명과 같이 없다면 생성, 있으면 있는 걸 반환하도록 구현하면 된다.

        여기서 필요한 개념은 static 변수이다.

         

        강의에서는 싱글톤 매크로를 생성하고 그것을 클래스 내부에서 사용하여 쉽게 싱글톤 패턴을 만들 수 있도록 하고 있다.

        여기서 static 변수는 메모리의 어떤 영역에 할당될까? -> 데이터 영역이다. - 정적 변수, 전역 변수가 이곳에 할당된다.

        그럼 여기서 알아가야할 정보는 클래스 내부에서 static 변수를 선언하면 클래스 전체의 크기에서 제외된다는 것이다!

         

        추가로 static 변수로 생성한 클래스는 2번 호출이 되면 객체의 초기화는 무시하고 이미 만들어진 객체를 반환한다.

         

        위 정보를 토대로 싱글톤을 구현하면

        #define SINGLE(type)									\
        					public:								\
        						static type* GetInstance()		\
        						{								\
        							static type mgr;			\
        							return &mgr;				\
        						}								\
        					private:							\
        						type();							\
        						~type();

        매크로로 이렇게 구현하고 사용할 수 있다.

         

        클래스 내부에 매크로를 사용하고

        GetInstance 함수로 클래스의 객체를 가져와서 사용하면 된다.

        추가로 private로 객체의 생성자와 소멸자는 호출할 수 없도록 만들었다.

        위와 같이 매크로로 만들면 클래스 내부에서 생성자와 소멸자를 만들 필요가 없어진다.

         

        일전에 작성해본 프로그램은 싱글톤 패턴이 상속을 통하여 구현되어 있었다.

        이 점은 이후 프로젝트를 진행할 때 코드를 비교하고 더 편하고 성능이 좋다고 판단되는 것을 사용하도록 하겠다.

         

        2. Core 클래스

        Core 클래스는 윈도우 프로그램에서 메시지가 없을 때 진행해야하는 동작을 관리하는 클래스이다.

        지난 실습에서 윈도우 프로그램에서 아무런 동작을 주지 않을 경우 거의 99프로 이상이 메시지가 발생하지 않았다.

        게임은 실시간으로 화면이 갱신되고 동작이 처리되어야 하기 때문에 메시지가 없는 동안에도 화면을 갱신하는 등의 처리가 필요하다.

        이런 동작을 처리하는 클래스가 바로 Core 클래스 이다.

        먼저 Core 클래스의 정의는 다음과 같다.

        class CCore
        {
        	SINGLE(CCore);
        public:
        	int init(HWND hWnd_, POINT ptResolution_);
        	void progress();
        
        	HWND GetMainHandle() { return m_hWnd; }
        
        private:
        	void update();
        	void render();
        
        private:
        	HWND		m_hWnd;				// 메인 윈도우 핸들
        	POINT		m_ptResolution;		// 메인 윈도우 해상도
        	HDC		m_hDC;				// DC의 핸들값
        
        	HBITMAP 	m_hBit;
        	HDC		m_memDC;
        };

        클래스의 최상단 부분에 SINGLE 매크로가 정의되어 있으므로 해당 클래스는 싱글톤 방식으로 동작한다.

        그리고 init 함수에서 화면의 크기 정의 각종 매니저 클래스의 초기화 등의 작업이 진행된다.

        이어서 progress 함수에서는 화면의 갱신을 처리한다.

        update() -> render()의 작업을 진행한다.

         

        소유하고 있는 멤버 변수로는

        메인 윈도우 핸들, 해상도, 그리기 위한 DC 오브젝트

        이후 설명할 더블 버퍼링을 위한 비트맵 핸들, 비트맵 핸들을 위한 백업 DC 오브젝트이다.

         

        1. Core 클래스의 초기화

        먼저 Core 클래스를 사용하려면 초기화 작업을 진행해야 한다.

        Core 클래스의 init 함수는 메인 윈도우 핸들과 해상도 크기를 POINT 구조체로 받고 있는데

        POINT 구조체에 받은 해상도 값을 기준으로 윈도우의 크기를 조절한다.

         

        해당 과정을 살펴보자

        int CCore::init(HWND hWnd_, POINT ptResolution_)
        {
            m_hWnd = hWnd_;
            m_ptResolution = ptResolution_;
        	
            RECT rt = {0, 0, m_ptResolution.x, m_ptResolution.y};
            
            AdjustWindowRect(&rt, WS_OVERLAPPEDWINDOW, true);
            SetWindowPos(m_hWnd, nullptr, 100, 100, rt.right - rt.left, rt.bottom - rt.top, 0);
            
            m_hDC = GetDC(m_hWnd);
            
            return S_OK;
        }

        Core 클래스의 가장 기본적인 초기화 단계이다.

        1. 먼저 핸들과 크기 정보를 받아온 뒤 Core 클래스에 저장해놓고 사용하기 위해 멤버로 저장한다.
        2. 이어서 윈도우 크기를 설정하기 위해 창 크기에 맞게 Rect 구조체를 선언하고
        3. AdjustWindowRect WS_OVERLAPPEDWINDOW 옵션을 주고 함수를 호출하면
        4. 윈도우의 메뉴 종료창 기타 화면을 차지하는 요소들의 크기까지 모두 합친 크기를 Rect에 저장한다.
        5. SetWindowPos를 통해 윈도우의 크기를 지정한다.
        6. 3번째 인자부터 x, y, cx, cy 인데 x, y는 창의 위치 좌표이고, cx, cy는 너비와 높이이다.
        7. 그리기를 위한 DC를 생성한다.

        여기까지 진행하면 기본적인 그리기를 수행할 수 있는 Core 클래스의 초기화는 마무리 된다.

        마지막에서 S_OK는 윈도우 스타일의 성공을 반환하는 방법이다.

         

        윈도우 프로그램의 메인 함수에서 객체의 초기화를 진행할 때

        FAILED 매크로를 활용하여 좀 더 윈도우 스타일의 프로그램을 설계할 수 있다.

        반대의 의미는 S_FALSE 이다.

        if (FAILED(CCore::GetInstance()->init(g_hWnd, POINT{1280, 768})))
        {
            MessageBox(nullptr, L"Core 객체 초기화 실패", L"Error", MB_OK);
            return FALSE;
        }

        WinMain 함수에서 다음과 같이 초기화하면 된다.

        2. Progress 함수

        Progress 함수에서는 윈도우 메시지가 없을 때 실질적인 상태 업데이트, 그리기 작업을 수행한다.

        progress 함수 내부에서는 

        1. update 함수 호출
        2. render 함수 호출

        을 진행한다.

         

        먼저 테스트를 진행하기 위한 간단한 오브젝트 클래스를 만들어주었다.

        class CObject
        {
        public:
        	CObject();
        	~CObject();
        
        	void SetPos(Vec2 vPos_) { m_vPos = vPos_; }
        	void SetScale(Vec2 vScale_) { m_vScale = vScale_; }
        
        	Vec2 GetPos() { return m_vPos; }
        	Vec2 GetScale() { return m_vScale; }
        
        private:
        	Vec2 m_vPos;
        	Vec2 m_vScale;
        };

        위치 좌표와 크기를 가지고 있는 간단한 클래스이다.

        Vec2 타입은 float 타입 2개를 가지고 있는 Vector 타입이다.

        유니티나 언리얼 등 게임 엔진에서 사용하는 그 Vector가 맞다!

         

        해당 클래스의 객체를 Core 클래스의 init 함수에서 생성해준다.

        Pos는 m_ptResolution의 절반 값 즉, 화면 중앙에 배치하고 크기는 100, 100으로 설정하였다.

         

        1. 그리기 작업

        render 함수에서 작업을 진행한다.

        void CCore::render()
        {
        	Vec2 vPos = g_obj.GetPos();
        	Vec2 vScale = g_obj.GetScale();
        
        	Rectangle(m_hDC
        		, int(vPos.x - vScale.x / 2.f)
        		, int(vPos.y - vScale.y / 2.f)
        		, int(vPos.x + vScale.x / 2.f)
        		, int(vPos.y + vScale.y / 2.f));
        }

        m_hDC 들고 있던 DC 오브젝트로 사각형을 그려주면 된다.

        여기까지 진행하면 화면의 중앙에 사각형이 그려진다.

         

        그럼 이어서 키입력을 받아 사각형을 이동시켜보겠다.

         

        2. 업데이트 작업

        update 함수에서 작업을 진행한다.

        update 함수에서 키입력 작업을 진행할 때 메시지 기반을 사용하지 않으므로 어떻게 작업해야할까?

        이 때 사용하는 것이 바로 비동기 키 입력 함수이다.

        다음과 같이 설계하면 된다.

        void CCore::update()
        {
        	Vec2 vPos = g_obj.GetPos();
        
        	if (GetAsyncKeyState(VK_LEFT) & 0x8000)
        	{
        		vPos.x -= 1.f;
        	}
        
        	if (GetAsyncKeyState(VK_RIGHT) & 0x8000)
        	{
        		vPos.x += 1.f;
        	}
        
        	g_obj.SetPos(vPos);
        }

        GetAsyncKeyState 함수를 사용하여 키가 입력되었는지 체크하고 객체의 상태를 업데이트 하면 된다.

        GetAsyncKeyState 함수의 반환값은 0x8000 과 0x8001 그리고 0x0000 과 0x0001 인데

        여기서 최상위비트가 8인 경우가 키가 입력된 경우이다. 그러므로 0x8000을 and 연산해주어서 키 입력을 체크하면 된다.

         

        3. TimerManager 클래스

        여기까지 진행하고 사각형의 움직임을 보면 매우 빠른 속도로 움직이는 것을 확인할 수 있다.

        그 이유는 반복문의 속도가 매우 빠르기 때문이다.

        GetTickCount 함수로 frame 속도를 체크해보면 2~3만번 정도로 굉장히 빠른 것을 알 수 있다.

        그리고 PC의 성능에 따라 다른 frame을 나타내는데 이대로 게임에 적용하게 되면 컴퓨터 성능에 따른 이동속도 차이가 나타나게 된다. 이걸 동기화 시키기 위해 TimerManager 를 선언하고 만들어주면 된다.

        이를 시간 동기화 기법이라고 한다.

        class CTimeManager
        {
        	SINGLE(CTimeManager);
        
        public:
        	void		init();
        	void		update();
        
        	double		GetDeltaTime() { return m_dDeltaTime; }
        	float		GetfDeltaTime() { return (float)m_dDeltaTime; }
        
        private:
        	LARGE_INTEGER	m_llCurCount;
        	LARGE_INTEGER	m_llPrevCount;
        	LARGE_INTEGER	m_llFrequency;
        
        	double			m_dDeltaTime; // 프레임 간의 시간 값
        	double			m_dAcc;		  // 1초 체크를 위한 누적 시간
        
        	UINT			m_iCallCount; // 초당 호출 횟수
        	UINT			m_iFPS;
        };

        Timer 클래스도 물론 SINGLE 매크로를 사용하여 싱글톤 패턴으로 만들어주고

        init() 함수로 초기화를 하고 update로 매번 프레임을 계산해주면 된다.

         

        1. Timer 클래스의 초기화

        void CTimeManager::init()
        {
        	// 현재 카운트
        	QueryPerformanceCounter(&m_llPrevCount);
        
        	// 초당 카운트 횟수
        	QueryPerformanceFrequency(&m_llFrequency);
        }

        m_llPrevCount 변수에 현재 카운트를 계산하고

        m_llFrequency 변수에 초당 카운트 횟수를 계산하면 된다.

         

        여기서 이때 초기화 한 변수로 프레임 횟수를 계산하고 계속 사용하게 되면 문제점은

        컴퓨터 CPU 사용량에 따라 프레임 횟수가 뜰쭉날쭉할 수 있기 때문에 매 초마다 프레임 횟수를 계산해주어야 한다.

         

        2. Timer 클래스 갱신

        void CTimeManager::update()
        {
        	QueryPerformanceCounter(&m_llCurCount);
        
        	// 프레임마다 차이값
        	m_dDeltaTime = (double)(m_llCurCount.QuadPart - m_llPrevCount.QuadPart) / (double)m_llFrequency.QuadPart;
        	m_llPrevCount = m_llCurCount; // 다음 계산을 위하여 갱신
        
        	++m_iCallCount;
        	m_dAcc += m_dDeltaTime; // DT 누적
        
        	if (m_dAcc >= 1.)
        	{
        		m_iFPS = m_iCallCount;
        
        		m_dAcc = 0.;
        		m_iCallCount = 0; // fps
        
        		wchar_t szBuffer[255] = {};
        		swprintf_s(szBuffer, L"FPS : %d, DT : %f", m_iFPS, m_dDeltaTime);
        		SetWindowText(CCore::GetInstance()->GetMainHandle(), szBuffer);
        
        	}
        }

        위 업데이트 로직을 분석해보면

        1. 호출 되었을 때의 카운트를 얻어온 뒤
        2. 프레임마다의 차이값을 계산하고 이전 카운트를 현재 카운트로 갱신해준다.
        3. CallCount를 매 순간마다 1씩 더해주고
        4. DeltaTime에 누적시킨다.
        5. 만약 1초가 지났다면
        6. FPS에 CallCount를 대입하고
        7. 다른 변수들을 다시 0으로 초기화 시킨 뒤
        8. 윈도우창의 타이틀에 FPS와 DT를 나타낸다.

        다음과 같이 DeltaTime을 계산하여 이동시킬 때 곱해주면 된다.

        여기서 DeltaTime의 의미는 프레임 당 시간을 의미하고 1 / Frame에 해당한다.

        이동량 X DeltaTime을 하면 프레임 당 이동해야 하는 거리를 알 수 있다.

        이것이 바로 시간 동기화 기법이다.

         

        여기서 DeltaTime은 예전에 유니티를 공부할 때 사용해본적이 있다.

        그때도 이동을 시킬 때 DeltaTime을 곱하여 이동량을 구했었는데 자세히 알게된 것은 이번이 처음이다.

         

        DeltaTime을 GetDeltaTime 함수를 통하여 구해오게 되는데 이 과정이 귀찮으므로 간단하게 매크로를 사용하여 정의하였다.

        #define DT CTimeManager::GetInstance()->GetDeltaTime()
        #define fDT CTimeManager::GetInstance()->GetfDeltaTime()

        이렇게 선언하면 편하게 DT, fDT를 사용하여 DeltaTime을 구해오고

        void CCore::update()
        {
        	Vec2 vPos = g_obj.GetPos();
            
        	if (GetAsyncKeyState(VK_LEFT) & 0x8000)
        	{
        		vPos.x -= 200.f * fDT;
        	}
        
        	if (GetAsyncKeyState(VK_RIGHT) & 0x8000)
        	{
        		vPos.x += 200.f * fDT;
        	}
        
        	g_obj.SetPos(vPos);
        }

        업데이트 함수에서 다음과 같이 사용할 수 있다.

         

        그런데 여기까지 진행하면 또 다른 문제점이 있다.

        화면에 잔상이 남을 뿐더러 버벅이는 현상도 있다.

         

        3. 더블 버퍼링

        이 현상은 도화지에 비교하면 편하다.

        한 도화지에 계속 그리고 있을 뿐더러 계속 다시 그려줌으로 잔상도 남고 버벅이는 현상도 발생한다.

        이를 해결하기 위해 화면을 그릴 도화지를 하나 더 준비하면 해결이 된다.

        미리 그림을 그려놓고 그 그림을 이전의 그림과 바꿔치기 하면 된다.

        위 비유에서 그림은 비트맵이다.

        이 비트맵을 2개 준비하는 것이 바로 더블 버퍼링 기법이다!

         

        먼저 새 도화지를 준비하는 과정을 알아보겠다.

        int CCore::init(HWND hWnd_, POINT ptResolution_)
        {
            // ------ 윗 부분의 코드는 동일
        	m_hBit = CreateCompatibleBitmap(m_hDC, m_ptResolution.x, m_ptResolution.y);
        	m_memDC = CreateCompatibleDC(m_hDC);
        
        	HBITMAP hOldBit = (HBITMAP)SelectObject(m_memDC, m_hBit);
        	DeleteObject(hOldBit);
        
        	return S_OK;
        }

        새로운 비트맵을 만들어준다. CreateCompatibleBitmap 으로 생성

        백업 DC를 생성한다. CreateCompatibleDC

        그리고 SelectObject 함수를 통하여 그림을 그릴 비트맵을 바꾸어준다.

         

        여기서 비트맵이란 화면 픽셀을 모두 묶어서 비트맵이라고 표현한다.

         

        위와 같이 설정하고 앞으로는 m_memDC를 통하여 그림을 그려주면 된다.

        void CCore::render()
        {
        	Rectangle(m_memDC, -1, -1, m_ptResolution.x + 1, m_ptResolution.y + 1);
        
        	Vec2 vPos = g_obj.GetPos();
        	Vec2 vScale = g_obj.GetScale();
        
        	Rectangle(m_memDC
        		, int(vPos.x - vScale.x / 2.f)
        		, int(vPos.y - vScale.y / 2.f)
        		, int(vPos.x + vScale.x / 2.f)
        		, int(vPos.y + vScale.y / 2.f));
        
        
        	BitBlt(m_hDC, 0, 0, m_ptResolution.x, m_ptResolution.y, m_memDC, 0, 0, SRCCOPY);
        }

         

        Rectangle 함수를 화면 크기보다 더 크게 설정하여 기존에 있던 그림을 모두 지워준다.

        m_memDC를 사용하여 그림을 그린다.

        BitBlt 함수를 사용하여 memDC에 그려둔 비트맵을 메인 DC에 바꿔치기 한다.

         

        위 작업을 모두 진행하면 버벅이는 현상과 잔상이 남는 현상을 모두 해결할 수 있다!

        화면을 바뀌치는 작업으로 프레임의 수는 아주 많이 감소하였다. 그러나 아직까지 600프레임 이상은 나온다.

        복사 비용은 항상 동일하므로 여기서 더 프레임이 감소하기는 쉽지 않다고 한다.

         

        나중에 만약 DirectX를 공부하게 되면 CPU가 아닌 GPU를 사용해서 그래픽 처리를 하게 되는데 이때는 더 빠른 속도를 보인다고 한다.

         

        여기까지 포스팅을 마치도록 하겠다.

        반응형

        'WindowsAPI' 카테고리의 다른 글

        윈도우즈 API 게임 제작 준비 05 - Object Class  (0) 2023.09.26
        윈도우즈 API 게임 제작 준비 04 - KeyManager, SceneManager  (1) 2023.09.24
        윈도우즈 API 게임 제작 준비 02 - 메시지 처리, 그리기, 메시지 기반 단점 보완  (0) 2023.09.20
        윈도우즈 API 게임 제작 준비 01 - Windows 데스크톱 애플리케이션 분석  (0) 2023.09.19
        윈도우즈 API 정복 3장 - 출력  (0) 2023.09.18
        다음글
        다음 글이 없습니다.
        이전글
        이전 글이 없습니다.
        댓글
      조회된 결과가 없습니다.
      스킨 업데이트 안내
      현재 이용하고 계신 스킨의 버전보다 더 높은 최신 버전이 감지 되었습니다. 최신버전 스킨 파일을 다운로드 받을 수 있는 페이지로 이동하시겠습니까?
      ("아니오" 를 선택할 시 30일 동안 최신 버전이 감지되어도 모달 창이 표시되지 않습니다.)
      목차
      표시할 목차가 없습니다.
        • 안녕하세요
        • 감사해요
        • 잘있어요

        티스토리툴바