개발일지/소프트 렌더러

소프트웨어 렌더러 만들기 - 13 (삼각형 채우기, 보간)

hwi.middle 2024. 7. 22. 00:10

여기까지의 작업내용: https://github.com/hwi-middle/HimchanSoftwareRenderer/tree/4b2ed704877d380fac65cc97ae67b3457b3f060a

 

GitHub - hwi-middle/HimchanSoftwareRenderer: C++로 구현한 소프트웨어 렌더러입니다.

C++로 구현한 소프트웨어 렌더러입니다. Contribute to hwi-middle/HimchanSoftwareRenderer development by creating an account on GitHub.

github.com

무게중심 좌표 vs 스캔라인

저번 게시물에서 삼각형의 와이어프레임을 그렸으니 이제 색칠해볼 차례다. 내가 알고있는 선에서는 무게중심 좌표와 스캔라인 방식 중 어떻게 구현할 지 고민해보았다.

 

나는 둘 중에 스캔라인을 택했다. 커다란 이유는 없었다. 그게 더 구현하기 재밌어보였고, 자주 참고하던 CK렌더러는 무게중심 좌표를 이용하고 있어서 이것과 다르게 만들고 싶었다.

 

작년 전공 수업의 강의자료가 남아있어서 아주 좋은 참고자료가 되었고, <게임 프로그래밍을 위한 3차원 그래픽스> (한정현 저)도 참고했다.

 

참고할 자료가 없어요

스캔라인을 구현하면서 느낀게, 참고할 자료가 많이 없었다. <리얼 타임 렌더링>에도 관련 내용이 없을 줄은 몰랐다. DX9 용책에도 없고, <게임 엔진 아키텍처>(사실 여긴 없을만함)에도 없고... 작년 전공 수업 강의자료 아니었으면 절대 구현 못했을 것 같다.

 

강의자료랑 <게임 프로그래밍을 위한 3차원 그래픽스> 책 2권을 번갈아 참고하면서 구현했다. 코드에는 2개의 자료가 적절히 섞여있다고 보면 될 것 같다. 스캔라인 기본 구현은 강의자료를, 보간에 대한 내용은 서적을 참고했다.

 

찾다 찾다 스캔라인에 대해 처음으로 고안한 1967년도 논문까지 뒤적거렸다. 그런데 스캔 상태가 말도 아니어서 '와 이게 1967년 논문... 디토감성 쩐다...' 같은 생각 하고 있었는데 알고보니 멀쩡한 PDF본도 있었다. ㅋㅋㅋㅋㅋ

아주 멀쩡한 버전... 물론 화면을 촬영한 부분은 어쩔 수 없이 뭉개져있다. 하여튼 별 소득은 없었고 그냥 개발일지니까 적어보는 소소한 에피소드이다.

스캔라인 알고리즘

스캔라인은 기본적으로 그 이름이 말해주듯이 위에서 아래로 스캔해가면서 가로 선을 그어 도형을 채우는 방식이다. 여기서는 삼각형을 그리는 구현으로 범위를 좁힌다.

직접 파워포인트로 그려보았다

그러니까 위에서 아래로 가로선을 긋는다는건 이런거다. 스캔하듯이, 차례대로.

 

V1, V2, V3는 Y축 좌표에 대해 오름차순으로 정렬해야하며, 그림에서는 이미 그렇게 되어있다. 구현은 스캔라인의 시작점과 끝점을 그려주는 식으로 하면 된다. 그럼 시작점과 끝점은 어떻게 알아내느냐? 직선의 방정식을 세우면 된다.

 

여기서 특이한 점은 y값을 1씩 증가시키면서 x값을 찾아야한다는 사실이다. 그렇기 때문에 y에 대해 식을 정리하는게 아니라, x에 대해 식을 정리해서 x = ay + b같은 형태로 직선의 방정식을 정의하고, 이를 이용해 스캔라인을 그려가면 된다.

 

단색 칠하기

void WinRenderer::DrawTriangle(const Vector2& InPos1, const Vector2& InPos2, const Vector2& InPos3, const Color InColor)
{
	std::array<Vector2, 3> Points = { InPos1, InPos2, InPos3 };
	std::sort(Points.begin(), Points.end(), [](const Vector2& InLhs, const Vector2& InRhs) { return InLhs.Y < InRhs.Y; });

	float x1 = Points[0].X;
	float y1 = Points[0].Y;
	float x2 = Points[1].X;
	float y2 = Points[1].Y;
	float x3 = Points[2].X;
	float y3 = Points[2].Y;

	float a12 = y1 != y2 ? (float)(x2 - x1) / (float)(y2 - y1) : 0.f;
	float a13 = (float)(x3 - x1) / (float)(y3 - y1);
	float a23 = y2 != y3 ? (float)(x3 - x2) / (float)(y3 - y2) : 0.f;

	for (int y = y1; y <= y2; ++y)
	{
		float xLeft = x1 + a12 * (y - y1);
		float xRight = x1 + a13 * (y - y1);

		if (xLeft > xRight)
		{
			std::swap(xLeft, xRight);
		}

		DrawLine(Vector2(xLeft, y), Vector2(xRight, y), InColor);
	}

	for (int y = y2; y <= y3; ++y)
	{
		float xLeft = x2 + a23 * (y - y2);
		float xRight = x1 + a13 * (y - y1);

		if (xLeft > xRight)
		{
			std::swap(xLeft, xRight);
		}

		DrawLine(Vector2(xLeft, y), Vector2(xRight, y), InColor);
	}
}

자, 요렇게 기본적인 DrawTriangle 함수를 작성했다. 완성된게 아니라 스캔라인이 잘 작동하는지 확인하기 위한 중간단계여서 네이밍 컨벤션이 지켜지지 않은 부분이 있다.

아무튼 그렇게 그려낸 사각형. 예쁘게 잘 채워지는 것을 확인할 수 있다.

버텍스 컬러 보간 준비

자, 그렇다면 삼각형 그릴 때 필수 코스. 버텍스 컬러를 보간해서 채우는 처리를 해보자. 어차피 이렇게 UV 좌표도 보간하는거라서 유의미한 성과가 될 것이다. 스캔라인에서 버텍스에 있는 데이터를 보간하려면, 지금 그리고 있는 두 변에 대해 값을 보간하고, X좌표에 따라서 또 값을 보간해주면 된다.

struct Vertex
{
public:
    Vector2 Position;
    ::Color Color;
    Vector2 UV;

    FORCEINLINE constexpr Vertex() = default;
    FORCEINLINE constexpr Vertex(const Vector2& InPos) : Position(InPos) { }
    FORCEINLINE constexpr Vertex(const Vector2& InPos, const ::Color& InColor) : Position(InPos), Color(InColor) { }
    FORCEINLINE constexpr Vertex(const Vector2& InPos, const ::Color& InColor, const Vector2& InUV) : Position(InPos), Color(InColor), UV(InUV) { }
};

먼저 버텍스 컬러를 보간하려면 버텍스가 있어야하는거니까... Vertex 구조체를 만들어준다. 매우 간단하게 2차원 좌표, 컬러, UV를 가지고 있다. 추후에 동차좌표계로 3D를 구현한다면 위치가 Vector4가 되어있을테지만 당장은 2D이므로 Vector2로 선언했다.

class WinRenderer
{
public:
    // ...
    void DrawTriangle(const Vertex& InVertex1, const Vertex& InVertex2, const Vertex& InVertex3, const Color InColor);
    // ...
}

그에 맞춰서 단순히 좌표만 받던 DrawTriangle 함수를 Vertex로 입력받도록 한다.

 

DrawLine 함수 수정 (사실 아니었다??)

자, 그런데 문제가 있다. 지금의 구현 방식으로는 Line은 단색으로만 그릴 수 있다. Y축으로 내려가면서 X축으로는 컬러를 바꿔가며 색을 찍어야하는데, 지금의 DrawLine 함수는 그렇게 할 수가 없다. 단순히 Color를 받아서 그 색으로 픽셀을 찍어나가기 때문이다.

void WinRenderer::DrawLine(const Vector2& InStartPos, const Vector2& InEndPos, const std::function<Color(float X, float Y)> InColorFunc)
{
	// ...
	if (bIsGradualScope)
	{
		while (X != EndPosScreen.X)
		{
			SetPixel(X, Y, InColorFunc(X, Y));
			// ...
		}
	}
	else
	{
		while (Y != EndPosScreen.Y)
		{
			SetPixel(X, Y, InColorFunc(X, Y));
			// ...
		}
	}
}

그래서 DrawLine 함수에 std::function<Color(float, float)>을 받아서 X, Y좌표를 파라미터로 받아 색상을 반환하는 함수를 호출해주는 식으로 구현했다.

void WinRenderer::DrawLine(const Vector2& InStartPos, const Vector2& InEndPos, const Color& InColor)
{
	DrawLine(InStartPos, InEndPos, [&](float X, float Y) -> Color { return InColor; });
}

물론 그냥 Color를 지정해서 찍고 싶은 경우에는 기존처럼 Color만 넘겨줄 수 있도록 오버로딩도 제공한다.

 

나름 괜찮은 구현 같아서 뿌듯했다. 그런데 이 구현은 딱히 쓸모가 없어졌다...(절망) 그에 관한 이야기는 뒤에서 이어서 적도록 하겠다.

버텍스 컬러 보간 구현

void WinRenderer::DrawTriangle(const Vertex& InVertex1, const Vertex& InVertex2, const Vertex& InVertex3, const Color InColor)
{
	std::array<Vertex, 3> Vertices =
	{
		InVertex1,
		InVertex2,
		InVertex3
	};
	std::for_each(Vertices.begin(), Vertices.end(), [&](Vertex& InVertex) {
		InVertex.Position = ScreenPoint::CartesianToScreen(InVertex.Position, Width, Height);
		});
	std::sort(Vertices.begin(), Vertices.end(), [](const Vertex& InLhs, const Vertex& InRhs) { return InLhs.Position.Y < InRhs.Position.Y; });

	int32 X1 = Vertices[0].Position.X;
	int32 Y1 = Vertices[0].Position.Y;
	int32 X2 = Vertices[1].Position.X;
	int32 Y2 = Vertices[1].Position.Y;
	int32 X3 = Vertices[2].Position.X;
	int32 Y3 = Vertices[2].Position.Y;

	// Degenerate triangle
	if ((X2 - X1) * (Y3 - Y1) == (X3 - X1) * (Y2 - Y1))
	{
		return;
	}

	float A12 = Y1 != Y2 ? (X2 - X1) / static_cast<float>(Y2 - Y1) : 0.f;
	float A13 = (X3 - X1) / static_cast<float>(Y3 - Y1);
	float A23 = Y2 != Y3 ? (X3 - X2) / static_cast<float>(Y3 - Y2) : 0.f;

	Color DeltaColorStart = (Y2 != Y1) ? (Vertices[1].Color - Vertices[0].Color) / static_cast<float>(Y2 - Y1) : Color::White;
	Color DeltaColorEnd = (Y3 != Y1) ? (Vertices[2].Color - Vertices[0].Color) / static_cast<float>(Y3 - Y1) : Color::White;
	Color ColorStart = Vertices[0].Color;
	Color ColorEnd = Vertices[0].Color;

	for (int Y = Y1; Y <= Y2; ++Y)
	{
		int32 XStart = X1 + A12 * (Y - Y1);
		int32 XEnd = X1 + A13 * (Y - Y1);

		DrawLine(
			ScreenPoint::ScreenToCartesian(Vector2(XStart, Y), Width, Height),
			ScreenPoint::ScreenToCartesian(Vector2(XEnd, Y), Width, Height),
			[&](float InX, float InY) -> Color {
				return Math::Lerp(ColorStart, ColorEnd, (InX - XStart) / static_cast<float>(XEnd - XStart));
			});

		ColorStart += DeltaColorStart;
		ColorEnd += DeltaColorEnd;
	}

	DeltaColorStart = (Y3 != Y2) ? (Vertices[2].Color - Vertices[1].Color) / static_cast<float>(Y3 - Y2) : Color(0, 0, 0, 0);

	// 첫 번째 루프를 건너뛰는 경우가 있으므로 다시 계산
	ColorStart = Vertices[1].Color;
	ColorEnd = Vertices[0].Color + DeltaColorEnd * (Y2 - Y1);

	for (int Y = Y2; Y <= Y3; ++Y)
	{
		int32 XStart = X2 + A23 * static_cast<float>(Y - Y2);
		int32 XEnd = X1 + A13 * static_cast<float>(Y - Y1);

		DrawLine(
			ScreenPoint::ScreenToCartesian(Vector2(XStart, Y), Width, Height),
			ScreenPoint::ScreenToCartesian(Vector2(XEnd, Y), Width, Height),
			[&](float InX, float InY) -> Color {
				return Math::Lerp(ColorStart, ColorEnd, (InX - XStart) / static_cast<float>(XEnd - XStart));
			});

		ColorStart += DeltaColorStart;
		ColorEnd += DeltaColorEnd;
	}
}

일단 수정된 DrawLine 기반으로 구현한 버전이다.

결과는 멋지게 잘 나온다. 여러가지 형태의 삼각형에서 잘 적용됨을 확인했다.

 

버텍스 컬러 보간 개선

자, 그러면 앞서 진행했던 DrawLine 함수에 대한 수정이 왜 불필요하게 되었는지 살펴보자.

 

일단 스캔라인 알고리즘은 가지고 있는 데이터가 스크린 좌표계임을 전제로 한다. 반면, DrawLine 함수는 데카르트 좌표임을 전제로 한다. 그렇기 때문에 불필요한 좌표계 변환이 늘어난다.

 

뿐만 아니라, 굳이 브레젠험 직선 알고리즘을 이용할 필요가 없다. 스캔라인의 특성상 Y좌표가 같은 직선을 하나씩 긋는거라 복잡한 연산 없이 그냥 반복문을 돌면서 SetPixel 함수를 호출하는 것으로 충분하다.

 

DrawLine 함수의 수정이 스캔라인 구현을 위한 것임을 감안하면 DrawLine 함수의 수정은 불필요한 것이었으며, DrawTriangle 함수 내에서 SetPixel 함수를 호출하는 식으로 구현할 수 있겠다고 판단했다.

void WinRenderer::DrawTriangle(const Vertex& InVertex1, const Vertex& InVertex2, const Vertex& InVertex3, const Color InColor)
{
	std::array<Vertex, 3> Vertices =
	{
		InVertex1,
		InVertex2,
		InVertex3
	};
	std::for_each(Vertices.begin(), Vertices.end(), [&](Vertex& InVertex) {
		InVertex.Position = ScreenPoint::CartesianToScreen(InVertex.Position, Width, Height);
		});
	std::sort(Vertices.begin(), Vertices.end(), [](const Vertex& InLhs, const Vertex& InRhs) { return InLhs.Position.Y < InRhs.Position.Y; });

	int32 X1 = Vertices[0].Position.X;
	int32 Y1 = Vertices[0].Position.Y;
	int32 X2 = Vertices[1].Position.X;
	int32 Y2 = Vertices[1].Position.Y;
	int32 X3 = Vertices[2].Position.X;
	int32 Y3 = Vertices[2].Position.Y;

	// Degenerate triangle
	if ((X2 - X1) * (Y3 - Y1) == (X3 - X1) * (Y2 - Y1))
	{
		return;
	}

	float A12 = Y1 != Y2 ? (X2 - X1) / static_cast<float>(Y2 - Y1) : 0.f;
	float A13 = (X3 - X1) / static_cast<float>(Y3 - Y1);
	float A23 = Y2 != Y3 ? (X3 - X2) / static_cast<float>(Y3 - Y2) : 0.f;

	Color DeltaColorStart = (Y2 != Y1) ? (Vertices[1].Color - Vertices[0].Color) / static_cast<float>(Y2 - Y1) : Color::White;
	Color DeltaColorEnd = (Y3 != Y1) ? (Vertices[2].Color - Vertices[0].Color) / static_cast<float>(Y3 - Y1) : Color::White;
	Color ColorStart = Vertices[0].Color;
	Color ColorEnd = Vertices[0].Color;

	for (int Y = Y1; Y <= Y2; ++Y)
	{
		int32 XStart = X1 + A12 * (Y - Y1);
		int32 XEnd = X1 + A13 * (Y - Y1);

		bool bIsSwapped = false;
		if (XStart > XEnd)
		{
			std::swap(XStart, XEnd);
			std::swap(ColorStart, ColorEnd);
			bIsSwapped = true;
		}

		XStart = Math::Clamp(XStart, 0, Width - 1);
		XEnd = Math::Clamp(XEnd, 0, Width - 1);
		for (int X = XStart; X <= XEnd; ++X)
		{
			SetPixel(X, Y, Math::Lerp(ColorStart, ColorEnd, (X - XStart) / static_cast<float>(XEnd - XStart)));
		}

		if (bIsSwapped)
		{
			std::swap(ColorStart, ColorEnd);
		}

		ColorStart += DeltaColorStart;
		ColorEnd += DeltaColorEnd;
	}

	DeltaColorStart = (Y3 != Y2) ? (Vertices[2].Color - Vertices[1].Color) / static_cast<float>(Y3 - Y2) : Color(0, 0, 0, 0);

	// 첫 번째 루프를 건너뛰는 경우가 있으므로 다시 계산
	ColorStart = Vertices[1].Color;
	ColorEnd = Vertices[0].Color + DeltaColorEnd * (Y2 - Y1);

	for (int Y = Y2; Y <= Y3; ++Y)
	{
		int32 XStart = X2 + A23 * static_cast<float>(Y - Y2);
		int32 XEnd = X1 + A13 * static_cast<float>(Y - Y1);

		bool bIsSwapped = false;
		if (XStart > XEnd)
		{
			std::swap(XStart, XEnd);
			std::swap(ColorStart, ColorEnd);
			bIsSwapped = true;
		}

		XStart = Math::Clamp(XStart, 0, Width - 1);
		XEnd = Math::Clamp(XEnd, 0, Width - 1);
		for (int X = XStart; X <= XEnd; ++X)
		{
			SetPixel(X, Y, Math::Lerp(ColorStart, ColorEnd, (X - XStart) / static_cast<float>(XEnd - XStart)));
		}

		if (bIsSwapped)
		{
			std::swap(ColorStart, ColorEnd);
		}

		ColorStart += DeltaColorStart;
		ColorEnd += DeltaColorEnd;
	}
}

 

그리하여, 최종적인 구현. 반복문 2개에 대해 코드 중복이 많아서 함수로 만들어서 정리해보기도 했는데, 그게 더 보기 안좋아지는 것 같아서(parameter가 너무 많아짐...) 그냥 다시 돌려놨다.

두근두근, 이제 텍스처링이 코앞으로!

이번에도 해냈다! 이번 구현은 특히나 참고할 자료가 적었고 여러 삽질로 일구어냈다. 아, 그래픽스, 그리고 프로그래밍이라는 것은 어찌도 나를 이렇게 설레게 만드는가! 요즘은 다른 시간이 아깝게 느껴질 정도로 렌더러 개발이 재미있다.

 

자료가 몇 없었지만 내 방 책상에서 이리저리 책을 뒤적이고, 인터넷 자료를 찾아보는 경험은 나의 열정을 스스로에게 확인받는 것 같아 가슴이 뜨거웠다.

 

이론을 이해하고 코드로 옮기는 것은 두 말할 것 없이 즐거웠다. 구조를 어떻게 세울지, 이건 어떻게 처리하는게 가독성에 좋을지 등등을 고민하는 과정은 항상 즐겨왔으니까.

 

디버깅을 하면서 왜 안되는지 고민하고, 이런 저런 값들을 살펴보면서 가설을 세우고 검증하고 고쳐나가는 것까지 재미있었다.

 

두근두근, 조만간 텍스처링도 구현할 수 있겠다! 내가 만든 렌더러로 텍스처를 입힌 사각형이 나오면 얼마나 예쁠까? ㅎㅎ

내가 굳게 믿는 것. '하면 된다.' 

또 개강하면 한동안 손 못대겠지만 나는 힘차게, 힘찬 렌더러를 완성할 것이다.

반드시 완주할 것이다.