[Graphics] Mathematical Concepts

[homogeneous divide] NDC->월드좌표계, 월드좌표계 -> NDC

doyyy_0 2025. 1. 24. 14:29

Perspective projection에서는 homogemeous divide가 필요합니다. 일반적인 공간변환과 다르게 투영 행렬이기 때문이고, NDC좌표계로의 변환이 필요하기 때문입니다.

 

일반적으로 directX의 hlsl에서는 뷰or월드 좌표계를 proj행렬을 곱하면, 클립좌표계(NDC아님)로 변환이 됩니다. 이 과정은 버텍스 쉐이더에서 이루어지고, 이 값을 픽셀 쉐이더로 넘겨줬을때 알아서 NDC좌표계로 그래픽스 파이프라인 내부적으로 변환이 됩니다. 다음 그림을 이해해 봅시다.

주의할 점은 vertex shader에서 proj를 한 결과가 2차원이 아닌 3차원인 clip space라는 것입니다. 여기서 z값은 추후 깊이버퍼로 뒤 물체가 가리는지 아닌지를 판단할 때 쓰입니다.

 

코드는 다음과 같습니다.

 //vs쉐이더
 output.posWorld = pos.xyz; // 월드 위치 따로 저장

 pos = mul(pos, viewProj);

 output.posProj = pos;

 

여기서 output.posProj가 ndc좌표계인 상태로 directx의 그래픽스 파이프라인으로 들어갈 수도 있고, 아닌 상태로 들어갈 수도 있습니다. 하지만 directx내부적으로 ndc좌표계로 변환후 최종적으로 screen space(해상도 좌표계)로 변환이 됩니다. screen space는 float4 position : SV_POSITION;를 말합니다. 이 position이 directx의 그래픽스 파이프라인 안에 들어가기 전에는 ndc든 clip이든 상관없습니다. 들어가면 자동으로 ndc좌표계로 변환된 후 screen space로 나타납니다. 즉 vs에서 내가 조종할 때는 cilp이든 ndc로든 변형해서 넣어주기만 하면됩니다. 하지만 ps쉐이더에서 이 float4 position : SV_POSITION;를 이용하면 ndc좌표계의 값이 아닌 screen space값이 됩니다. 예를들어 맨 오른쪽 아래의 값을 찍어보면 (1,-1)이 아닌 (1280,720)의 값이 나오는 것이죠.

 

그럼 반대로 NDC좌표계를 뷰좌표계나 월드좌표계로 변환하려면 어떻게 해야할까요? hlsl에서는 수동으로 w로 나눠줘야합니다. depthOnlyBuffer로 ndc좌표계를 가져오는 과정으로 확인해봅시다.

float4 TexcoordToView(float2 texcoord)
{
    float4 posProj;

    // [0, 1]x[0, 1] -> [-1, 1]x[-1, 1]
    //ndc좌표계의 위치를 알려준다는 의미
    posProj.xy = texcoord * 2.0 - 1.0;
    posProj.y *= -1; // 주의: y 방향을 뒤집어줘야 합니다.
    posProj.z = depthOnlyTex.Sample(linearClampSampler, texcoord).r; //.r인 이유는 DepthOnlySRV의 format이 srvDesc.Format = DXGI_FORMAT_R32_FLOAT; 이기 때문
    posProj.w = 1.0;

    // ProjectSpace -> ViewSpace
    float4 posView = mul(posProj, invProj);
    posView.xyz /= posView.w;
    
    return posView;
}

이 때, 월드좌표를 proj했을때 ndc좌표계로 변환하기위해 그래픽스 파이프라인 내부에서 /w를해주는건 알겠습니다만, 왜 ndc좌표계에 역행렬을 곱할때 /w를 해줘야할까요? 

 ndc좌표에 invProj를 곱하면 (x/z,y/z,1,1/z)이 나옵니다. 따라서 1/z로 나눠주면 다시 (x,y,z,1)이 되기 때문에 우리가 원하는 월드좌표의 값이 나오는 것이죠.

 

그러면 hlsl말고 DirectX에서 NDC좌표계를 월드좌표계로 변환하는 과정을 봅시다. 다음은 PickingRay를 구하는 과정 코드입니다.

// OnMouseMove()에서 m_cursorNdcX, m_cursorNdcY 저장

// ViewFrustum에서 가까운 면 위의 커서 위치 (z값 주의)
Vector3 cursorNdcNear = Vector3(m_cursorNdcX, m_cursorNdcY, 0.0f);

// ViewFrustum에서 먼 면 위의 커서 위치 (z값 주의)
Vector3 cursorNdcFar = Vector3(m_cursorNdcX, m_cursorNdcY, 1.0f);

// NDC 커서 위치를 월드 좌표계로 역변환 해주는 행렬
Matrix inverseProjView = (viewRow * projRow).Invert();

// ViewFrustum 안에서 PickingRay의 방향 구하기
Vector3 cursorWorldNear =
    Vector3::Transform(cursorNdcNear, inverseProjView);
Vector3 cursorWorldFar =
    Vector3::Transform(cursorNdcFar, inverseProjView);
Vector3 dir = cursorWorldFar - cursorWorldNear;
dir.Normalize();

 

여기서는 왜 w로 안나눠주었냐면, Transform함수에서 자동으로 /w를 처리해서 값을 계산하기 때문입니다.