여기까지의 작업내용: https://github.com/hwi-middle/HimchanSoftwareRenderer/tree/51cb9f0f79f7447f93ead08c7fea180eec1bce6e
얼렁뚱땅 버그 천국
삼각형을 그리는 처리는 간단했다. 그냥 DrawLine 3번 하면 되니까. 그리고 버텍스 버퍼와 인덱스 버퍼에 대한 개념을 전공 수업에서 배워서 알고 있으니까.
그런데, 2가지 버그가 또 나를 괴롭혔다. 바로 직전 글에서 메모리 누수 잡고 성능 최적화를 했었는데 또 디버깅을 해야했다. 시간 자체가 아주 오래걸리지는 않았지만, 에너지 소비가 커서 오늘은 좀 게임도 하고 머리를 식히면서 보냈다.
버그 기록
발생했던 버그는 아래와 같다.
- 무한루프 현상: 창을 Resize하면 '간헐적으로' 무한루프를 돌면서 렌더링이 되지 않았다.
- 이 오류는 Width와 Height 값을 직접 수정하여 실행했을 때는 발생하지 않지만, 마우스로 직접 Resize 시 발생하는 것이 확인되었다.
- Y축 반전현상: 픽셀을 찍을 때 Y축으로 반전되어 출력되었다.
무한루프 현상
먼저 무한루프 현상이다. 이 버그는 창을 Resize하면 간헐적으로 응답없음 상태로 렌더링이 되지 않는 버그였다. 무한루프였다.
Break Point를 걸어도 좋지만, 이 경우에는 간헐적으로 발생하는 문제로 일단 무한루프가 어디서 발생하는지 찾는 것이 중요했기 때문에 무한루프가 발생할 때까지 창의 사이즈를 건드렸다가 무한루프가 시작되면 Break All로 디버깅을 시작했다.
확인해보니 WinRenderer::DrawLine 함수에서 발생하는 무한루프였다. 브레젠험 알고리즘을 통해 직선을 그리는 부분이다.
Watch 창을 통해 확인해보니 대략적인 상황파악은 된다. Y가 무한히 감소하고 있는 것이다.
왜 그럴까, 이유를 찾는데 한참이 걸렸다. 처음에는 브레젠험 알고리즘이나 코헨-서덜랜드 알고리즘을 구현하는 과정에서 잘못처리한 부분이 있을까 싶어 두 눈 부릅뜨고 쳐다봐도 문제가 보이지 않았다.
이런 저런 정보를 얻기 위해 DrawLine을 호출하기 직전에 ScreenSpace의 좌표를 출력하다보니, 이런 것이 눈에 띄었다. 221.5, 321.5 같은 실수형이 보인다. 여기서 아차 싶었다. Vector2는 float형이고 스크린 좌표계는 int형이다.
여기에서 Y는 int로 선언된 변수이고 EndPosScreen은 Vector2로서 float 성분을 가진다. 여기에서 float이 소수점을 가진다면 이 동치 연산은 영원히 성립하지 않게 되어 무한루프가 발생한 것이다.
FORCEINLINE static Vector2 CartesianToScreen(const Vector2& InPoint, const uint32 Width, const uint32 Height)
{
return Vector2(static_cast<int>(InPoint.X + Width * 0.5f), static_cast<int>(-InPoint.Y + Height * 0.5f));
}
그래서 데카르트 좌표를 스크린 좌표로 바꿔주는 CartesianToScreen 함수에서 int로 캐스팅해주는 방식으로 수정했다.
이제 이런저런 사이즈로 창을 Resize 해주어도 잘 그려진다!
왜 '간헐적으로' 발생했는가?
FORCEINLINE static Vector2 CartesianToScreen(const Vector2& InPoint, const uint32 Width, const uint32 Height)
{
return Vector2(InPoint.X + Width * 0.5f, -InPoint.Y + Height * 0.5f);
}
(수정 전) 코드를 보면 X 성분에는 Width * 0.5를, Y 성분에는 Height * 0.5를 더해주고 있다. 창의 Width와 Height가 모두 짝수라면 0.5를 곱해도(즉 2로 나누어도) 소수점이 생기지 않는다. 그러나 Width와 Height 중 둘 중 하나가 홀수가 되는 순간 그 성분에는 0.5라는 실수가 더해져 무한 루프가 발생하는 것이다.
실제로 무한 루프가 발생했을 때 Break All로 중단하고 Watch 창에서 ScreenSize를 확인해보면 Width나 Height가 홀수임을 확인할 수 있었으며, 코드 레벨에서 Width나 Height에 홀수를 넣고 실행하면 별도로 Resize하지 않아도 무한루프가 즉시 발생하는 것을 확인했다. (제대로 고친게 맞는지 검증)
이 버그가 난해했던 이유는 Width와 Height 값을 직접 수정하여 실행했을 때는 발생하지 않지만, 마우스로 직접 Resize 시 발생했다고 잘못 생각했기 때문이다. 하지만, 실제로는 해상도에 홀수가 섞여있을 때 발생하는 문제였다.
이것을 조기에 발견하지 못했던 이유는, 코드레벨에서 Width와 Height 값을 설정할 때 640 * 480, 800 * 600, 1280 * 720 등 상식적인 해상도에서만 확인했기 때문이었다.
Resize시 발생하는 버그였다는 점을 고려했다면 해괴망측한(?) 데이터도 넣어봤어야 했다. 이 부분은 반성해야겠다.
Y축 반전 현상
이 버그는 픽셀을 찍을 때 Y축으로 반전되어 출력되는 버그였다. 다소 이상했다. 이렇게 중대한 오류라면 진작에 걸러내지 못한게 납득되지 않는다.
static constexpr float SQUARE_HALF_SIZE = 50.f;
static constexpr uint32 VERTEX_CNT = 4;
static constexpr uint32 TRI_CNT = 2;
static constexpr Vector2 VertexBuffer[VERTEX_CNT] = {
(Vector2(-SQUARE_HALF_SIZE, -SQUARE_HALF_SIZE)),
(Vector2(-SQUARE_HALF_SIZE, SQUARE_HALF_SIZE)),
(Vector2(SQUARE_HALF_SIZE, SQUARE_HALF_SIZE)),
(Vector2(SQUARE_HALF_SIZE, -SQUARE_HALF_SIZE))
};
static constexpr uint32 IndexBuffer[TRI_CNT * 3] = {
0, 1, 2,
0, 2, 3
};
일단 버텍스 버퍼와 인덱스 버퍼는 이렇다.
- 0번 버텍스: 왼쪽 아래에 위치
- 1번 버텍스: 왼쪽 위에 위치
- 2번 버텍스: 오른쪽 위에 위치
- 3번 버텍스: 오른쪽 아래에 위치
- 렌더링 순서: (0, 1, 2) -> (0, 2, 3)
그러면 사각형은 이런식으로 그려져야 할 것이다.
그런데 렌더링 결과가 이상하다. Y축으로 반전되어 나온다. 사실 사선 방향이 반대인 것으로 '뭔가 잘못됐다'는 것을 눈치챈 것이지, Y축으로 반전됐다는 사실은 선분을 하나씩 그어가면서 확인한 결과이다.
그렇다면 왜 Y축으로 반전된걸까? 일단 DrawLine 함수를 괜히 흘겨본다. 하지만 아까 무한 루프를 해결하면서 DrawLine 함수에 대한 검토는 끝냈다. DrawLine에서 검증된 알고리즘은 브레젠험 알고리즘을 제외하고 내 실수가 들어갈만한 곳은 스크린 좌표계 변환과 픽셀 찍기 정도이다.
FORCEINLINE static Vector2 CartesianToScreen(const Vector2& InPoint, const uint32 Width, const uint32 Height)
{
return Vector2(static_cast<int>(InPoint.X + Width * 0.5f), static_cast<int>(- InPoint.Y + Height * 0.5f));
}
괜히 또 흘겨본다. 하지만 지금 상황에서 의심할 만한 것은 Y축에 대한 처리인데, 죽었다 깨어나도 저 수식이 틀렸을 리는 없다.
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();
}
그렇다면 SetPixel 부분이 문제일까? 한번 또 흘겨본다. 포인터 연산을 잘못했을까? 아니다. 그럴리가. Y좌표와 Width를 곱하고 X좌표를 더해주는 처리는 완벽하다.
Win32 API에서 X축은 오른쪽으로 갈 수록 증가고 Y축은 아래쪽으로 갈 수록 증가하기 때문에 메모리 구조와 완전히 일치하기 때문에 이렇게 2차원 배열 연산하듯이 쓸 수 있는 것이다.
... 그럼 뭐지?
SetPixel이 문제다
뭐가 문젠지 확실하게 하기 위해서 순정(?) SetPixel을 써보니 정상적으로 출력되는 것을 알았다. 그렇다면 확실히 나의 SetPixel 함수가 문제인 것이다. 왜지? 왜일까.
왼쪽 위가 원점에 오른쪽이 X축 아래쪽이 Y축. 이 대전제가 깨지지 않는 이상 이 코드는 틀리지 않았다. 그렇다면, 나의 '대전제'가 깨진걸까? 위쪽이 Y축이 되도록 말이다.
혹시 DIB 섹션은 좌표계가 다른가? 하지만 MSDN에도 그런 말은 없었다.
하지만 이런게 있다는 사실은 알았다. 그러면 ScreenBuffer를 초기화 해줄 때 뭔가 이런 것을 잘못 집어넣은건 아닐까?
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);
ScreenBuffer가 초기화 되는 부분이다. 여기서 뭔가 잘못된게 있을 것 같다. MapMode 같은건 안보이지만, bmiHeader 부분에 뭔가 있을 것 같다.
typedef struct tagBITMAPINFO {
BITMAPINFOHEADER bmiHeader;
RGBQUAD bmiColors[1];
} BITMAPINFO, FAR *LPBITMAPINFO, *PBITMAPINFO;
bmiHeader는 내부적으로 BITMAPINFOHEADER 타입인 것을 확인, MSDN에서 찾아본다.
쿠궁. biHeight값의 부호에 따라 원점이 정해지는 것이었다. 젠장, 이런게 어딨어!
Height라며! Height라며!! Height에 음수가 뭔 소린데!!
bmi.bmiHeader.biHeight = -Height;
ㅎㅎ... 그리하여, biHeight에는 음수를 지정했다.
짜잔. 드디어 정상적으로 출력된다... ㅠㅠ
왜 이제야 발견되었는가?
자체적인 SetPixel 함수보다 DrawLine 함수가 먼저 구현되었다. DrawLine 함수를 만들 때에는 이리저리 테스트해보며 좌표에 맞게 그려지는지 확인했었는데, SetPixel을 직접 구현한 뒤(즉 더블버퍼링을 구현한 뒤)에는 별 생각이 없었다. 당연히 잘 작동하겠거니 생각했었다.
그 후에는 X, Y축을 표시해주는 선만 그리거나 시간에 따라 '움직이는' 점을 구현했기 때문에 더욱 눈치채기 어려웠다.
디버깅은 어려워
디버깅은 참 어렵다. 과거의 내가 저지른 잘못들을 헨젤과 그레텔처럼 되짚어가며 찾아내야하니 말이다. 무엇보다, 내 가설이 완전히 잘못되었거나 버그의 재현조건을 잘못 이해하고 있을 때는 더욱 어려워진다.
오늘의 내가 그랬다. '여기가 잘못됐겠거니' 하고 코드를 한참 들여다봤지만 알고보니 엉뚱한 곳을 보고 있었고 '이 버그는 이럴때 발생되는 것이구나' 생각하고 원인을 찾으려니 도무지 알 수가 없었다.
API에 대한 이해도를 높이고, 더 신중하게 코드를 작성해야겠다. 버그를 안만드는게 가장 좋지만, 그런 사람은 아마 없을 것이다. 그렇기에 디버깅 능력이 중요한 것이 아닐까. 디버깅 경험 했다고 생각하고, 오늘 하루 마무리 짓자.
'개발일지 > 소프트 렌더러' 카테고리의 다른 글
소프트웨어 렌더러 만들기 - 14 (텍스처링) (1) | 2024.07.25 |
---|---|
소프트웨어 렌더러 만들기 - 13 (삼각형 채우기, 보간) (1) | 2024.07.22 |
소프트웨어 렌더러 만들기 - 11 (메모리 누수 해결 및 성능 최적화) (1) | 2024.07.17 |
소프트웨어 렌더러 만들기 - 10 (DeltaTime 및 fps 측정 구현) (0) | 2024.07.17 |
소프트웨어 렌더러 만들기 - 9 (Resize 대응) (0) | 2024.07.17 |