여기까지의 작업내용:
https://github.com/hwi-middle/HimchanSoftwareRenderer/tree/ae54dc291b2d7497b31f674c9235fbbd080ba54d
삽질의 기록
졸업 작품을 하다보니 렌더러에 아예 신경을 못쓰고 있었다. 방학 후 조금 쉬다가, 며칠을 매달려서 CK렌더러를 분석하고 이를 모방한 렌더러 구조를 만들고 더블 버퍼링도 구현했다.
1학년 때 '윈도우 프로그래밍' 과목에서 부교재로 썼던 WinAPI 책도 보고 동아리 창고에서 주워온 윈도우즈 API 정복 책도 샅샅이 뒤져가며 다시 API에 대한 공부를 해가면서 CK렌더러를 분석했다. 코드라는게 처음부터 복잡해지는게 아닐테니 큰그림부터 하나하나 뜯어가면서 이해해나갔다.
신기하게도 어느 순간 구조가 익혀지기 시작했다. 물론 그렇다고 해서 순탄하지만은 않았다. 온갖 삽질을 반복했다. 하지만 분명히 즐거웠다! 시간이 쑥쑥 가버려서 아쉬울 지경이었다.
나의 숙원사업(?)이었던 렌더러 구조 갖추기, 그리고 더블 버퍼링 구현에 대한 내용을 블로그에 실을 수 있어서 기쁘다!
렌더러 구조 만들기
LRESULT CALLBACK WndProc(HWND hWnd, UINT iMessage, WPARAM wParam, LPARAM lParam)
{
HDC hdc;
PAINTSTRUCT ps;
switch (iMessage)
{
case WM_PAINT:
{
hdc = BeginPaint(hWnd, &ps);
// ----- Draw below -----
DrawHelper::DrawLine(hdc, Vector2(Width, Height), Vector2(0, 0), Vector2(0, 800), RGB(255, 0, 0));
DrawHelper::DrawLine(hdc, Vector2(Width, Height), Vector2(80, 80), Vector2(180, 150), RGB(0, 255, 0));
DrawHelper::DrawLine(hdc, Vector2(Width, Height), Vector2(-450, 950), Vector2(70, -50), RGB(0, 0, 255));
// ----- Draw above -----
EndPaint(hWnd, &ps);
break;
}
// ...
}
//...
}
기존에는 WM_PAINT 이벤트에서 직접 픽셀을 찍어주고 있었다. 더블 버퍼링 처리도 없었다. 내가 보기에도 썩 맘에 들지 않았는데, 이 부분을 손댔다. 꽤나 큰 작업이고 어떻게 해야할지 감도 못잡고 있던 상황이었는데, 사실 그 막막함 때문에 렌더러에 더 손을 못댄 것도 있는 것 같다.
class Application
{
public:
Application(uint32 InWidth, uint32 InHeight, WinRenderer* InRenderer);
~Application();
void Tick();
private:
void PreUpdate();
void Update();
void LateUpdate();
void Render();
void PostUpdate();
};
일단 간단한 루프 구조를 갖추었다. 이렇게 첫 삽을 뜨는 부분은 얌얌코딩님의 강의(https://www.youtube.com/playlist?list=PLWKwcHKTXy5RSkINElI7wZOwn9z4RcJff)가 도움이 되었다.
너무 어렵게만 느껴져서 렌더러 구현을 방치하고 있던 중에, 유튜브 얌얌코딩님을 알게 되었다. 얌얌코딩님은 무료로 강의와 노트를 제공해주시는 분인데, C++을 사용하여 유니티 엔진을 모방하는 코스도 있었다! 이 강의의 초반부가 나에게 큰 도움이 되었다. 얌얌코딩님, 감사합니다!
class WinRenderer
{
public:
WinRenderer() = default;
~WinRenderer();
bool Initialize(uint32 InWidth, uint32 InHeight);
void Release();
FORCEINLINE void SetPixel(int32 InX, int32 InY, const Color& InColor);
void SwapBuffer();
void FillBuffer();
private:
HWND Handle;
HDC ScreenDC, MemDC;
HBITMAP OriginalBitmap, MemBitmap;
Color32* ScreenBuffer;
uint32 Width, Height;
// ...
}
그리고 실질적으로 픽셀을 찍고 직선을 긋는 등의 처리를 할 렌더러 클래스를 구현한다. 이 부분은 CK렌더러를 참고했는데, 윈도우의 핸들이라던가 DC 같은 것들을 WinMain을 처리하는 곳이 아닌 외부에서 선언해서 사용할 생각을 아예 못했었다.
bool WinRenderer::Initialize(uint32 InWidth, uint32 InHeight)
{
Width = InWidth;
Height = InHeight;
Handle = ::GetActiveWindow();
ScreenDC = ::GetDC(Handle);
MemDC = ::CreateCompatibleDC(ScreenDC);
BITMAPINFO bmi;
memset(&bmi, 0, sizeof(BITMAPINFO));
bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
bmi.bmiHeader.biWidth = Width;
bmi.bmiHeader.biHeight = Height;
bmi.bmiHeader.biPlanes = 1;
bmi.bmiHeader.biBitCount = 32;
bmi.bmiHeader.biCompression = BI_RGB;
MemBitmap = ::CreateDIBSection(MemDC, &bmi, DIB_RGB_COLORS, (void**)&ScreenBuffer, NULL, 0);
OriginalBitmap = (HBITMAP)::SelectObject(MemDC, MemBitmap);
return true;
}
그런 다음 이렇게 초기화해준다. DIBSection이라는 것이 매우 생소했는데, 윈도우즈 API 정복(김상형 저)을 읽어보니 관련한 설명을 찾을 수 있었다.
- DDB: Device Dependent Bitmap. 장치에 종속적인 비트맵으로 호환되지 않는 다른 장치에서 제대로 출력할 수 없다.
- DIB: Device Independent Bitmap. 장치에 독립적인 비트맵으로 어디서나 제대로 출력할 수 있다.
- DDB가 DIB에 비해 내부적으로 훨씬 가볍고 결국 DC에 선택될 수 있는 것은 DDB 뿐이다. 그래서 어차피 DIB도 DDB로 변환해야한다.
- DIB 섹션: DIB이지만 HBITMAP형으로 표현되는 DIB와 DDB의 중간 포맷이다.
CreateDIBSection의 4번째 인자로 2차원 포인터를 받는데, 여기에 내 포인터를 넘겨서 래스터 데이터에 대한 포인터를 받는다(래스터 데이터의 크기만큼 메모리 공간을 할당해줌). 여기서 ScreenBuffer는 Color32* 타입이다. Color 클래스에 대한 내용은 바로 다음절에 다루겠다.
Color 클래스 추가
struct Color
{
public:
FORCEINLINE constexpr Color() = default;
FORCEINLINE explicit constexpr Color(float InR, float InG, float InB, float InA = 1.f) :
R(InR), G(InG), B(InB), A(InA) {}
FORCEINLINE explicit constexpr Color(Color32& InColor)
{
constexpr float OneOver255 = 1.f / 255.f;
R = float(InColor.R) * OneOver255;
G = float(InColor.G) * OneOver255;
B = float(InColor.B) * OneOver255;
A = float(InColor.A) * OneOver255;
}
float R = 0.f;
float G = 0.f;
float B = 0.f;
float A = 1.f;
// ...
}
대충 이렇게 생긴 Color 클래스를 추가했다. Color 클래스는 0~1의 실수값을 가지는 RGBA 컬러를 지정한다. 0~255의 정수값을 가지는 RGBA컬러는 Color32에서 정의한다. 언리얼엔진에서는 각각을 LinearColor, FColor(여기서 F는 그냥 prefix)라고 하고 유니티에서는 Color, Color32라고 하는데 유니티쪽 표기가 더 마음에 들어서 가져왔다.
내부 코드는 CK렌더러와 언리얼 엔진을 참고했다.
struct Color32
{
public:
FORCEINLINE constexpr Color32() : R(0), G(0), B(0), A(0) {}
FORCEINLINE explicit constexpr Color32(byte InR, byte InG, byte InB, byte InA = 255) :
R(InR), G(InG), B(InB), A(InA) {}
// ...
union
{
struct
{
byte B, G, R, A;
};
uint32 ColorValue;
};
};
Color32 구조체는 이렇게 구현된다. 앞서 언급한 바와 같이 Color32 클래스는 스크린 버퍼에도 활용된다. BRGA로 구성된 이유는 DIBSection의 포맷이 그렇다.
#define RGB(r,g,b) ((COLORREF)(((BYTE)(r)|((WORD)((BYTE)(g))<<8))|(((DWORD)(BYTE)(b))<<16)))
애초에 COLORREF에서 사용하는 RGB 매크로를 보면 이렇게 선언되어있다. R채널 넣고, G채널 8칸 밀어넣고, B채널 16칸 밀어넣는 구조. 그러면 00BBGGRR이 된다. MSDN에 따르면 상위 바이트는 00으로 고정이라고 한다.
이러한 포맷에서 알파를 확장한다면 나는 당연히 비워놨던 상위 바이트를 알파로 채워 AABBGGRR이 될 줄 알고 ABGR 순으로 구조체를 짰는데, 아니었다. 이상하게 출력돼서 한참을 헤매다 BBGGRRAA 순으로 지정되어있음을 알았다. 더 알아봐도 그 이유는 잘 안나온다. 그냥 API가 그냥 그렇게 만들어져있겠거니, 받아들였다.
어쨌든, 요렇게 Color32 구조체를 작성해서 스크린 버퍼를 초기화하는 것까지 됐다.
픽셀 찍기
FORCEINLINE void WinRenderer::SetPixel(int32 InX, int32 InY, const Color& InColor)
{
if (!IsInScreen(InX, InY))
{
return;
}
int Index = InY * Width + InX;
ScreenBuffer[Index] = InColor.ToColor32();
}
FORCEINLINE bool WinRenderer::IsInScreen(const int32 InX, const int32 InY) const
{
if ((InX < 0 || InX >= Width) || (InY < 0 || InY >= Height))
{
return false;
}
return true;
}
그러면 이제 픽셀을 찍어보자. *(ScreenBuffer + Index)에 픽셀을 찍어야하는데 결국 ScreenBuffer[Index]랑 동치라서 이렇게 써봤다. 블로그에 글을 쓰다보니 전자가 더 직관적인가 싶기도 하지만...
void WinRenderer::SwapBuffer()
{
::BitBlt(ScreenDC, 0, 0, Width, Height, MemDC, 0, 0, SRCCOPY);
}
버퍼를 교체해주는 것은 간단하다. 그냥 BitBlt.
class WinRenderer
{
public:
// ...
void DrawLine(const Vector2& InScreenSize, const Vector2& InStartPos, const Vector2& InEndPos, const Color InColor);
private:
// ...
enum EViewportRegion : uint8
{
INSIDE_VIEWPORT = 0b0000,
LEFT = 0b0001,
RIGHT = 0b0010,
BOTTOM = 0b0100,
TOP = 0b1000
};
bool ClipLine(Vector2& InOutStartPos, Vector2& InOutEndPos, const Vector2& InMinPos, const Vector2& InMaxPos);
EViewportRegion ComputeViewportRegion(const Vector2& InPos, const Vector2& InMinPos, const Vector2& InMaxPos);
};
그리고 DrawLine 부분을 DrawHelper에서 WinRenderer로 가져왔다. 그리고 ViewportRegion을 그냥 typedef로 쓰고 있었는데 WinRenderer로 오면서 그런 멤버 변수가 있는게 좀 부자연스러워서 enum으로 바꿨다.
Application::Application(uint32 InWidth, uint32 InHeight, WinRenderer* InRenderer) : Renderer(InRenderer), Width(InWidth), Height(InHeight)
{
Renderer->Initialize(Width, Height);
}
Application::~Application()
{
}
void Application::Tick()
{
PreUpdate();
Update();
Render();
LateUpdate();
PostUpdate();
}
void Application::PreUpdate()
{
GetRenderer().FillBuffer();
}
void Application::Update()
{
posY = Math::Sin(t * 0.5f) * (Height / 2);
posX = Math::Cos(t * 0.5f) * (Width / 2);
}
void Application::LateUpdate()
{
t += 0.02f;
}
void Application::Render()
{
std::cout << "pos: (" << posX << ", " << posY << ")" << std::endl;
GetRenderer().DrawLine(Vector2(-(int)Width, posY), Vector2(Width, posY), Color::Red);
GetRenderer().DrawLine(Vector2(posX, -(int)Height), Vector2(posX, Height), Color::Green);
GetRenderer().DrawPoint(Vector2(posX, posY), Color::Blue);
GetRenderer().DrawPoint(Vector2(posX + 1, posY), Color::Blue);
GetRenderer().DrawPoint(Vector2(posX - 1, posY), Color::Blue);
GetRenderer().DrawPoint(Vector2(posX, posY + 1), Color::Blue);
GetRenderer().DrawPoint(Vector2(posX, posY - 1), Color::Blue);
GetRenderer().DrawPoint(Vector2(posX + 1, posY + 1), Color::Blue);
GetRenderer().DrawPoint(Vector2(posX + 1, posY - 1), Color::Blue);
GetRenderer().DrawPoint(Vector2(posX - 1, posY + 1), Color::Blue);
GetRenderer().DrawPoint(Vector2(posX - 1, posY - 1), Color::Blue);
}
void Application::PostUpdate()
{
GetRenderer().SwapBuffer();
}
그리고 Application으로 돌아와서, 이렇게 작성해보았다. 아직 delta time 같은게 없어서 LateUpdate에서 t에다가 0.02라는 리터럴을 더해주는 식으로 구현돼있다.
짜잔. 그렇게 해서 만들어진 이 간단한 데모 되시겠다.
해냈다 해냈어
뭐랄까, 오랜만에 진득하게 앉아서 프로그래밍만 했던 것 같다. 게임수학과 Win32 API 책들을 책상에 올려둔채 뒤적거리고, 다른 사람이 개발한 엔진과 렌더러 코드들을 보며 나의 렌더러를 만들어 나가는 것. 정말 시간가는 줄 모르고 붙잡고 있을 만큼 즐거운 일이다. 언리얼 엔진 소스 코드도 보고, 유니티 레퍼런스 코드도 보고, CK렌더러도 보고, 기타 깃허브 구석에 숨어있는 남의 레포도 보고... 낭만있었달까.
이제 렌더러 구조도 갖춰졌고, 삼각형을 그리고 텍스처를 입히는 구현까지 파죽지세로 이어나가보자!
'개발일지 > 소프트 렌더러' 카테고리의 다른 글
소프트웨어 렌더러 만들기 - 10 (DeltaTime 및 fps 측정 구현) (0) | 2024.07.17 |
---|---|
소프트웨어 렌더러 만들기 - 9 (Resize 대응) (0) | 2024.07.17 |
소프트웨어 렌더러 만들기 - 7 (직선 그리기) (0) | 2024.02.25 |
소프트웨어 렌더러 만들기 - 6 (다양한 크기의 Vector와 Square Matrix) (0) | 2024.02.20 |
소프트웨어 렌더러 만들기 - 5 (Custom Assertion) (0) | 2023.11.11 |