- 오늘 작업한 내용
※ 첫 번째 문제 발생 : 소멸자 가상화를 사용하지 않아 발생한 메모리 누수
프로젝트 작업 중 Actor를 상속받는 배경 클래스에서 클래스를 초기화하기 위해 파일 이름을 담은 string 포인터를 사용하면 릭이 남는 현상을 발견했다. 결론적으로 소멸자를 가상화함으로서 string의 소멸자가 호출되지 않아 릭이 남는 문제를 해결했다.
그걸 알아내기 까지 문제를 분석하면서 이것저것 시도해보았다.
아래는 문제를 찾으면서 되짚어본 메모리 누수 관련 주의점이다.
- Leak Check시 주의할 점
1. 당연하게도 Leak Check를 실행한 이후 시점에서 New로 동적할당된 메모리는 체크 대상이 아니므로 릭이 발생해도 잡히지 않는다.
2. 프로그램 종료시점에 잡히지 않아서 직접 정리해야되는 메모리들이 있다. 예를 들어 메모리가 데이터 영역에 있다고 하면 프로그램이 종료되어야 해당 메모리들이 소멸되어 정리가 되는데, 이런 경우에도 릭을 잡을 수 없다.
3. 업캐스팅으로 할당한 객체가 가상함수로 정의된 부모를 가질 경우 자식 클래스에서의 소멸자가 실행되지 않아 릭이 남을 수 있다. 실제로 이번 문제는 Actor부분에서 소멸자를 가상화하지 않아서 발생한 것으로 확인되었다. 관련 내용은 아래 가상 소멸자 부분에서 자세히 알아보자.
- 가상 소멸자(Virtual Destructor, 소멸자 가상화)와 릭
상위 클래스(부모)로 하위 파생(자식) 클래스를 참조할 때 상위 클래스 형식을 '추상 자료형'이라고 한다. 마치 '동물'의 파생 형식이라고 할 수 있는 '고양이'를 '동물'이라고 지칭하는 것이 당연하게 여겨지는 것 처럼, 추상 자료형은 상속 관계가 존재할 때 파생 클래스를 지칭하는 적절한 방법 중 하나이다. 이를 사용하면 업캐스팅과 같은 일을 할 수 있다.
그런데 이러한 추상 자료형을 이용해 동적 생성한 객체를 참조할 경우에는 심각한 메모리 누수 오류가 발생할 수 있다.
사실 메모리 개념을 제외하고서도 잠재적 문제가 발생할 수 밖에 없는 구조인데, 근본적으로 파생 형식의 소멸자(자식 클래스의 소멸자)가 호출되지 않기 때문이다.
여기서 다시 한번 생성자와 소멸자가 실행되는 순서를 되짚어보자.
B클래스가 A클래스를 상속하고 있다고 가정할 때, 생성자는 부모(A)->자식(B) 순으로 실행되고, 소멸자는 자식->부모순으로 실행된다.
그렇기 때문에 방금 위에서 언급한 '파생 형식의 소멸자가 호출되지 않는다'는 말은 소멸자 실행시 마땅히 먼저 실행되어야할 자식 클래스의 소멸이 일어나지 않는다는 것이고 그것은 메모리 누수(릭)으로 이어진다.
그렇다면 왜 이런 일이 벌어질까? 이는 소멸자가 가진 독특한 특수성 때문이다.
만약 A()라는 클래스의 생성자와 소멸자가 있다면 소멸자를 처리할 때 컴파일러는 앞에 virual 키워드가 붙지 않은 소멸자의 이름을 ~A가 아닌 ~라고 생각한다. 가상화되지 않은 일반적인 함수와 동일하게 판단해버리는 것이다.
그러므로 소멸자를 가상화하지 않은 상태로 사용자 코드(main 등)에서 작성자가 추상 자료형을 운영하고 객체를 동적 할당 및 해제한다면 문제가 발생한다.
예를 들어, 여기서 delete 연산을 실행한다고 가정하면 내부적으로는 참조 형식의 소멸자(부모)만 호출되고 실 형식의 소멸자(자식)는 호출되지 않는 심각한 문제가 발생하는 것이다.
이 문제를 해결하는 가장 간단한 방법은 기본 클래스(부모)의 소멸자 자체를 가상화해버리는 것이다.
class A
{
public:
virtual void Function()
{
}
virtual void Test()
{
std::cout << "Test" << std::endl;
}
A()
{
}
virtual ~A()
{
}
};
class B : public A
{
public:
void Test()
{
std::cout << "Test" << std::endl;
}
B()
{
}
~B()
{
}
};
int main()
{
A* NewA = new B();
NewA->B::Test();
delete NewA;
}
한 번 가상함수가 되면 영원히 가상 함수로 남게 되므로, 기본 형식이 가상 클래스인 경우 이를 상속받는 자식, 즉 파생 클래스의 소멸자 역시 가상 함수로 지정하기 위해 따로 예약어를 선언해주지 않아도 자동으로 가상화된다.
따라서 이렇게 하면 이 부모 클래스를 상속하는 모든 자식 클래스의 소멸자들이 동일하게 가상 소멸자로 선언된다.
이 상태로 가상 소멸자가 호출되면, 객체의 소멸과정에서 상속 구조 맨 아래의 유도 클래스에서 소멸자가 대신 호출되기 때문에 기초 클래스 소멸자가 원래 순서대로 차근차근 호출된다. 즉, 실 형식의 소멸자가가 어떤 자료형을 가지고 있던 간에 모든 소멸자를 호출할 수 있게 되는 것이다.
예전에는 가상 소멸자가 일반 소멸자보다는 속도가 느리기 때문에(가상화 자체가 한번 검색해서 적용되므로) 이런 사용방식을 피하려고 할 때도 있었지만, 현재에 와서는 그 속도 차이가 아주 미미한 정도이기 때문에 상관하지 않고 사용한다.
※ 두 번째 문제 발생 : 배경 크기 조절하기
BitBlt 함수 대신 TransparentBlt 함수를 사용해서 해결했다. 자세한 내용은 아래에서 서술한다.
어제 텍스처를 화면에 띄우는 것까지는 완료를 했는데 여기서 문제가 있었다.
원본 이미지가 화면 전체에 나와야 하는데 어제 만든 BitCopy 함수로는 크기를 마음대로 지정해 줄 수 없었다.
BitCopy 함수는 내부에 이미지를 카피하기 위해 카피할 이미지로부터 DC를 받아와 새로운 HDC에 할당하고, 그걸 BitBlt 함수에서 받아와 화면에 출력시키도록 굉장히 단순하게 구현된 함수인데 BitBlt 함수는 출력하는 이미지의 크기 조절이 불가능하기 때문에 이번 문제를 해결하기 위해서 함수 자체를 바꾸는 보완이 필요하다는 결론이 나왔다.
- BitBlt의 문제점
1. 출력하려는 Bitmap 객체를 그대로 화면에 뿌리기 때문에 이미지 크기를 변경할 수 없다.
2. 같은 이유로 이미지의 배경과 같이 특정 부분을 제외하고 출력할 수 없다.
- BitBlt의 대안
출력시 이미지 크기를 변경할 수 있고, 특정 색상을 제거할 수도 있는 TransparentBlt 함수를 사용한다.
BitBlt의 대안으로 활용할 함수 TransparentBlt를 사용하려면 msimg32 라이브러리를 사용해야 한다.
- #pragma comment
전에 사용하던 프로젝트 참조 기능과 동일한 기능이다. 이전에는 게임이 최종 실행되는 최하단 레벨의 프로젝트를 제외한 나머지 상위 프로젝트들은 모두 .lib 확장자를 가진 라이브러리로 만들어지게끔 설정해두었다. 그리고 각 프로젝트마다 상위 프로젝트를 참조 추가해서 간단히 사용했다. 이렇게 하면 참조로 추가한 프로젝트 내부의 CPP 파일들은 별도로 연결해주지 않아도 알아서 컴파일러가 인식할 수 있었기 때문에 CPP 파일 내용을 알지 못해 발생하는 외부 기호 오류가 일어나지 않았고, 이런 LINK 오류를 방지하는데 이 방식이 정말 편했다.
하지만 지금 사용하려는 라이브러리는 윈도우에서 제공하는 것이기 때문에 따로 프로젝트 참조 추가로 사용할 수가 없다.
따라서 이번엔 정석적으로 #pragma comment를 사용해봤다.
#pragma comment(lib, "msimg32.lib")
해당 라이브러리가 어디있는지 위치를 직접 확인하고 싶을 때는 아래 사진처럼 프로젝트 -> 속성 -> VC++ 디렉터리 -> 라이브러리 디렉터리에서 평가 값에 있는 링크들을 확인해보면 된다.
링크들을 확인해본 결과 아래 경로에 해당 라이브러리가 존재함을 확인할 수 있었다.
성공적으로 라이브러리를 등록했다면 이제 TransparentBlt 함수를 사용할 수 있다.
- TransparentBlt 함수
BOOL TransparentBlt(
[in] HDC hdcDest,
[in] int xoriginDest,
[in] int yoriginDest,
[in] int wDest,
[in] int hDest,
[in] HDC hdcSrc,
[in] int xoriginSrc,
[in] int yoriginSrc,
[in] int wSrc,
[in] int hSrc,
[in] UINT crTransparent
);
TransparentBlt 함수는 위에서 언급한 몇 가지 차이점을 제외하면 사용법이 BitBlt과 거의 유사하다.
인자도 앞부분은 모두 동일한데, 다른 점은 비트맵 객체가 연결된 시작 좌표 뒤에
비트맵에서 출력하려는 영역의 넓이와 높이 (즉, 둘을 합치면 이미지의 크기가 된다)가 추가 된다는 점이다.
마지막 인자로 BitBlt에선 출력을 어떻게 할지 사용자가 직접 결정했는데, 이 함수에서는 현재 연결된 비트맵에서 제거하고 싶은 색상을 RGB 컬러로 적어주면 알아서 해당 색상이 이미지에서 제거된 결과가 출력된다.
현재 배경 클래스에서 다음과 같은 코드를 넣어주면, 중간에 있는 FindTexture->GetScale() 코드 때문에 현재 이미지의 원본 스케일을 가져오게 된다.
BackBuffer->TransCopy(FindTexture, GetPos(), FindTexture->GetScale(), { 0,0 }, FindTexture->GetScale());
그러나 지금 작업하려는 화면은 배경이미지가 화면에 가득차야 하기 때문에, 따로 Scale을 확대한 이미지 크기를 넣어주어야 한다. 연산자 다중 정의로 필요한 연산들을 사용 가능하도록 만든 다음 적당한 값을 곱하여 키워준다.
void BackGround::Render()
{
GameEngineWindowTexture* FindTexture = ResourcesManager::GetInst().FindTexture(FileName);
if (nullptr == FindTexture)
{
return;
}
GameEngineWindowTexture* BackBuffer = GameEngineWindow::MainWindow.GetBackBuffer();
float4 Scale = FindTexture->GetScale();
Scale *= 2.0f;
BackBuffer->TransCopy(FindTexture, GetPos(), Scale, { 0,0 }, FindTexture->GetScale());
}
※ 세 번째 문제 발생 : 배경 이미지와 플레이어를 동시에 출력하면 플레이어 이미지가 깜빡이는 현상 (싱글 버퍼링 문제)
현재 윈도우와 똑같은 크기의 버퍼를 하나 더 만들어서(더블 버퍼링) 이미지가 서로 번갈아가며 출력되는 문제를 해결했다.
현재 코드에서는 배경 이미지가 그려진 다음 플레이어가 그려지도록 렌더링 순서가 정해져있다.
그렇기 때문에 먼저 배경 이미지가 버퍼에 그려지면, 이어서 플레이어가 배경이 있던 버퍼 자리를 점유하여 화면에 그려진다. 이처럼 이미지가 활성, 비활성이 반복되면서 플레이어 이미지가 깜빡이는 것처럼 보이게 된다.
근본적으로 이 문제는 화면을 출력할 수 있는 윈도우 DC에 두 개의 그림을 그린 것이 원인이므로 해결하기 위해서는 버퍼를 두 개 사용해야 한다. (윈도우 버퍼, 백 버퍼로 서로 다른 버퍼가 두 개 존재)
이것을 구현하기 위해 아래의 ResCreate 함수를 함께 만들었다. 이 함수는 비어있는 흰색 이미지 하나를 만드는 함수다.
void ResCreate(const float4& _Scale);
이미지라는 것은 근본적으로 2차원 배열이며, 각 배열 값 안에 색상 정보를 가지고 있다고 생각하면 된다.
그렇기 때문에 굳이 이미지를 로딩하지 않아도 모든 배열 안에 (255, 255, 255)의 RGB값을 가진 흰색이 존재하는 이미지를 만들어낼 수도 있는 것이다.
void GameEngineWindowTexture::ResCreate(const float4& _Scale)
{
// 윈도우의 HDC 기반으로 설정한 크기를 가진 빈 이미지를 하나 만든다.
HANDLE ImageHandle = CreateCompatibleBitmap(GameEngineWindow::MainWindow.GetHDC(), _Scale.iX(), _Scale.iY());
if (nullptr == ImageHandle)
{
MsgBoxAssert("이미지 생성에 실패했습니다.");
return;
}
BitMap = static_cast<HBITMAP>(ImageHandle);
ImageDC = CreateCompatibleDC(nullptr);
OldBitMap = static_cast<HBITMAP>(SelectObject(ImageDC, BitMap));
ScaleCheck();
}
이때 주의해야 할 점은 윈도우의 크기가 바뀌면 그 바뀐 크기에 맞춰서 화면을 다시 그려주어야 한다는 것이다.
void GameEngineWindow::SetPosAndScale(const float4& _Pos, const float4& _Scale)
{
Scale = _Scale;
// 만약 이미 버퍼안에 이미지가 있다면
if (nullptr != BackBuffer)
{
// 그 버퍼를 지운 뒤
delete BackBuffer;
// 이미지를 담을 버퍼를 새로 준비하고
BackBuffer = new GameEngineWindowTexture();
// 그 버퍼에 현재 윈도우 스케일에 맞춰 이미지를 넣는다.
BackBuffer->ResCreate(Scale);
}
RECT Rc = { 0, 0, _Scale.iX(), _Scale.iY() };
AdjustWindowRect(&Rc, WS_OVERLAPPEDWINDOW, FALSE);
SetWindowPos(hWnd, nullptr, _Pos.iX(), _Pos.iY(), Rc.right - Rc.left, Rc.bottom - Rc.top, SWP_NOZORDER);
}
다시 이미지를 그리도록 기능을 구현했으니 이번엔 윈도우의 소멸자에서도 더블 버퍼링에 의해 만들어진 버퍼를 비워야 한다.
GameEngineWindow::~GameEngineWindow()
{
if (nullptr != BackBuffer)
{
delete BackBuffer;
BackBuffer = nullptr;
}
if (nullptr != WindowBuffer)
{
delete WindowBuffer;
WindowBuffer = nullptr;
}
}
더블 버퍼링 기능의 실제 적용은 내일 마저 해보도록 하자.
'WinApi' 카테고리의 다른 글
[개발일지] 23-05-22 (0) | 2023.05.22 |
---|---|
[개발일지] 23-05-19 ~ 23-05-21 (0) | 2023.05.21 |