Graphics Techniques

[Rasterization] 삼각형의 레스터화

doyyy_0 2024. 10. 24. 20:23

이전 글에서는 Raytracing에 대해서만 공부해봤습니다. 이번엔 Rasterization에 대해 알아볼 건데요. 둘의 차이점은 뭘까요?

Ray Tracing은 보다 물리적으로 정확한 빛의 경로를 추적하여 렌더링하는 방식입니다. 영화나 애니메이션과 같은 비실시간 렌더링에서 많이 사용됩니다. Ray를 발사해서 반사, 그림자, 굴절 등을 반영하여 보다 현실적인 이미지를 나타냅니다. 반면, Rasterization은 3D 모델을 2D 화면에 투영하는 전통적인 렌더링 방식입니다. 이 방법은 주로 실시간 렌더링(예: 게임 엔진)에서 사용됩니다. 빛을 발사하는게 아니라 각 삼각형을 화면공간(스크린 좌표계)로 투영하는 방식을 사용하여 이미지를 나타냅니다. 

 

Raytracing은 픽셀마다 ray를 발사해서 모든 물체를 다 검사한 후 색을 결정합니다. 반면 Raterization은 물체를 스크린에 투영시킨 후 색을 결정합니다.

 

Raytracing (광추적) Rasterization (레스터화)
for 픽셀
    for 물체
        색 결정
for 물체
    for 픽셀
        색 결정

 

Raterization을 이해하기 앞서 진행순서를 알아야합니다.

0. 삼각형을 시계방향으로 정의 

1. 삼각형의 월드좌표계를 레스터 좌표계로 변환

2. 삼각형의 사각형으로 바운딩해 픽셀 계산량 최소화하기

3. 해당 픽셀이 삼각형 안에 포함되는지 확인하기

4. intorpolation하여 색 결정하기

 

 Raterization은 월드좌표계에서 픽셀로 투영시키는 작업을 먼저 합니다. 월드 좌표계는 일반적으로 다음 NDC(Normalized Device Coordinates)를 사용합니다. 이 좌표계를 픽셀좌표계 즉, 스크린 좌표계로 변환해야하는 것이죠. 

http://www.directxtutorial.com/Lesson.aspx?lessonid=111-4-1

변환 과정을 코드로 이해해보겠습니다.

vec2 Rasterization::ProjectWorldToRaster(vec3 point) {

    // 월드 좌표계의 원점이 우리가 보는 화면의 중심이라고 가정

    // NDC로 변환[-1, 1] x[-1, 1]
    // NDC(Normalized Device Coordinates)
    // NDC는 모니터의 실제 해상도와 상관 없이 정사각형이라는 점 주의
    // 그림: http://www.directxtutorial.com/Lesson.aspx?lessonid=111-4-1
    // 여기서는 width가 height보다 긴 경우만 고려

    const float aspect = float(width) / height;
    const vec2 pointNDC = vec2(point.x / aspect, point.y);

    // 레스터 좌표의 범위 [-0.5, width - 1 + 0.5] x [-0.5, height - 1 + 0.5]
    const float xScale = 2.0f / width;
    const float yScale = 2.0f / height;

    // NDC -> 레스터 화면 좌표계
    // 주의: y좌표 상하반전
    return vec2((pointNDC.x + 1.0f) / xScale - 0.5f,
                (1.0f - pointNDC.y) / yScale - 0.5f);
}

 

여기서 NDC에 대해 이해해야합니다. DirectX측이 표준 좌표계를 [-1,1] [-1,1]로 정해놨습니다. 하지만 NDC는 최종적으로 사용될 것이 아닌, 그냥 변환과정의 일부일 뿐입니다. 모니터의 비율이 다 다르기 때문에 변환과정에서 가로 세로를 1대1로 변환하고 다시 레스터좌표계로 변환해서 보내줍니다. 예를들어 모니터의 비율이 4:3이라고 가정합시다. 매개변수 point에는 [-1.33, 1.33] [-1, 1] 을 [-1,1] [-1,1] 로 const vec2 pointNDC = vec2(point.x / aspect, point.y); 이 부분에서 교정합니다. 약간 가로로 축소된 모습이겠죠? 여기서 [-1.33, 1.33] [-1, 1] 는 월드좌표계에서의 범위인데 이 이상 넘어가면 화면을 벗어나는것으로 간주합니다. 

 

또한 xScale과 yScale에 2.0을 곱해주는 이유는 가로 세로 길이가 각각 2이기 때문입니다. 마지막 return문장을 살펴보기전에pointNDC.x / xScale과 pointNDC.y / yScale을 살펴봅시다. 이렇게 하면 범위가 [-width,width] [-height, height]가 됩니다. 이때 레스터 좌표계는 x y축이 월드 좌표계와 다르게 y축이 아래 그림처럼 뒤바뀌어 있습니다. 따라서 -pointNDC.y로 부호를 바꿔줘야합니다.

 

 

예를들어 아래의 왼쪽그림은 월드좌표계 그림이고, 이걸 y축을 뒤집지않고 레스터 좌표계로 변환하면 그림이 위아래로 뒤집히기 때문입니다.

 

 

또한 레스터좌표계는 [0, width -1] [0 , height -1] 이므로 +1.0f를 해줍니다. 그런데 왜 레스터 좌표의 범위가 

 [-0.5, width - 1 + 0.5] x [-0.5, height - 1 + 0.5] 일까요? 그것은 픽셀의 끝부분까지 계산해야하기 때문입니다. 이해를 위해

이전글 : https://pdy0930.tistory.com/58 을 참고하시면 좋을 것 같습니다. 따라서 마지막에 -0.5f까지 해주면 범위가 정해집니다.

 

 

따라서 삼각형의 월드좌표를 각 레스터 좌표로 변환해줍니다. 코드는 다음과 같습니다.

    const auto v0 = ProjectWorldToRaster(triangle.v0.pos);
    const auto v1 = ProjectWorldToRaster(triangle.v1.pos);
    const auto v2 = ProjectWorldToRaster(triangle.v2.pos);

 

다음 차례는 삼각형의 범위를 정해 모든 픽셀을 계산하지 않고 필요한 부분만 계산하는 것입니다. 이걸 바운딩처리라고 하는데 삼각형을 감싸는 가장 작은 사각형의 범위를 계산하는 것입니다. 코드로 이해해 봅시다.

 auto xMin = min({v0.x, v1.x, v2.x});
 auto yMin = min({v0.y, v1.y, v2.y});
 auto xMax = max({v0.x, v1.x, v2.x});
 auto yMax = max({v0.y, v1.y, v2.y});
 
 xMin = clamp((int)xMin, 0, width - 1);
 yMin = clamp((int)yMin, 0, height - 1);
 xMax = clamp((int)xMax, 0, width - 1);
 yMax = clamp((int)yMax, 0, height - 1);

 

 

그 다음은 해당 픽셀의 지점 point가 삼각형 안에 있는지 판단하는 것입니다. 아래 그림으로 같이 이해하면 좋은데요. 삼각형의 안에 point가 있다면 세부분으로 쪼갠 삼각형을 구하는 과정 (외적을 이용) 했을 때, 모든 삼각형이 양수의 값을 띄어야 합니다. 벗어나면 외적의 값이 음수가 됩니다.

 

 

 

그리고 각 색깔을 intorpolation하여 넣어줍니다. 이 과정은 이전 글 : https://pdy0930.tistory.com/57

과 다음 코드를 보며 이해해봅시다!

 

float Rasterization::EdgeFunction(const vec2 &v0, const vec2 &v1,
                                  const vec2 &point) {

   
    const vec3 crossVec = cross(vec3{point - v0, 0.0f}, vec3{v1 - v0, 0.0f});
    const float crossVecSum = crossVec.x + crossVec.y + crossVec.z; 

    return crossVecSum;
}
for (size_t j = yMin; j <= yMax; j++) {
    for (size_t i = xMin; i <= xMax; i++) {

        // Rasterizing a triangle
        // 1. 픽셀이 삼각형에 포함되는지 확인
        // 2. 픽셀의 색 결정
        // 참고: A Parallel Algorithm for Polygon Rasterization

        // 3D에서 bary centric coordinates 구하던 것과 동일한데
        // 2D라서 z값을 0으로 고정하면 간단해짐
       

        const vec2 point = vec2(float(i), float(j));

        const float alpha0 = EdgeFunction(v2, v1 ,point); //v1,v2는 픽셀좌표화된것
        const float alpha1 = EdgeFunction(v0, v2, point);
        const float alpha2 = EdgeFunction(v1, v0, point);

        if (alpha0 >= 0.0f && alpha1 >=0.0f && alpha2 > 0.0f) {

            // 픽셀의 색 결정
            // 주의: 원근투영(perspective projection)에서는
            // depth 값을 고려해서 보정해줘야 합니다.

            // Bary-centric coordinates를 이용해서 color interpolation
            auto w0 = alpha0 / (alpha0 + alpha1 + alpha2);
            auto w1 = alpha1 / (alpha0 + alpha1 + alpha2);
            auto w2 = 1 - w0 - w1;
            //const vec3 color = vec3(1.0f, 1.0f, 1.0f);
            const vec3 color = triangle.v0.color * w0 +
                               triangle.v1.color * w1 +
                               triangle.v2.color * w2;
            pixels[i + width * j] = vec4(color, 1.0f);
        }
        else {
            const vec3 color = vec3(1.0f, 1.0f, 1.0f);
            pixels[i + width * j] = vec4(color, 1.0f);
        }

 

여기서 EdgeFunction에서 v0과 v1의 순서가 굉장히 중요합니다. 외적은 교환법칙이 성립하지 않기 때문이죠. 저는 임의의 삼각형의 한 점에서 point까지의 선을 왼손좌표계의 구부리기전 가리키는 곳으로 정했습니다. 물론 이 룰이 모든 삼각형에적용되기  위해선 다음 코드처럼 첫 vertex입력시 모두 시계방향으로 입력해줘야합니다. 

// 시계방향인지 반시계방향인지 주의
triangle.v0.pos = {0.0, 0.5, 1.0f};
triangle.v1.pos = {1.33, -0.5, 1.0f};
triangle.v2.pos = {-1.33, -0.5, 1.0f};
triangle.v0.color = {1.0f, 0.0f, 0.0f}; // Red
triangle.v1.color = {0.0f, 1.0f, 0.0f}; // Green
triangle.v2.color = {0.0f, 0.0f, 1.0f}; // Blue

 

첫 삼각형을 이렇게 정의했다면 다음과 같은 결과가 나옵니다.

 

 

 

 

 

 

 

강의 출처 :

https://honglab.co.kr/courses/graphicspt2

 

HongLab 홍정모 연구소

홍정모의 컴퓨터 그래픽스 새싹코스

honglab.co.kr