[게임 수학] 3차원 원근 투영과 깊이값

ounols·2021년 11월 18일
4

게임 수학

목록 보기
6/9
post-thumbnail

🧐 해당 파트는 게임 개발 환경을 구성하는 컴퓨터 그래픽스(Computer Graphics)를 이해하기 위한 기초 수학의 간단한 개념에 대해 설명하고 있습니다!

혹여나 이해가 잘 안되거나 잘못된 정보를 발견하시게 되었다면 관련해서 피드백 해주시면 정말 감사하겠습니다!

1. 3차원 NDC 공간

지금까지 저희가 배워왔던 NDC는 2차원의 형태로 진행했었습니다.
음.. 2차원으로도 큰 문제가 없었는데 굳이 3차원으로 확장을 진행해야할까요?

1-1. 왜 NDC 공간을 3차원으로 확장해야 할까요?

2차원 NDC를 사용한 경우3차원 NDC를 사용한 경우

혹시 위의 그림에서 차이가 보이시나요?
둘의 차이는 멀리있는 물체도 가까이 있는 물체를 신경 안쓰고 렌더링을 하게 되느냐 안되느냐 이런 차이가 생깁니다.

이를 간단하게 설명한다면 3차원으로써의 깊이값에 따른 렌더링이 필요하기 때문에 3차원으로 확장시키는겁니다!

그럼 3차원 NDC좌표를 사용한 투영 행렬 공식은 어떻게 되나요?
일단 먼저 기존 NDC 행렬에서 차원 하나가 추가되었으므로 기존 ww값을 뒤로 미루고 zz값을 넣습니다.
P vview=[da0000d00ijkl0010][vxvyvz1]=[davxdvy?vz]P\cdot\ v_{view}=\left[\begin{matrix}\frac{d}{a}&0&0&0\\0&d&0&0\\i&j&k&l\\0&0&-1&0\\\end{matrix}\right]\left[\begin{matrix}v_x\\v_y\\v_z\\1\\\end{matrix}\right]=\left[\begin{matrix}\frac{d}{a}\cdot v_x\\d\cdot v_y\\?\\-v_z\\\end{matrix}\right]

이제 여기서 사용하지 않는 값을 생략시킬 수 있습니다.
zz에 해당되는 행을 보면 서로 직교하여 전혀 영향을 주지않는 i,ji, j요소(각각 zz요소의 x,yx, y축에 해당)를 0으로 설정해줍시다.

P vview=[da0000d0000kl0010][vxvyvz1]=[davxdvy?vz]P\cdot\ v_{view}=\left[\begin{matrix}\frac{d}{a}&0&0&0\\0&d&0&0\\0&0&k&l\\0&0&-1&0\\\end{matrix}\right]\left[\begin{matrix}v_x\\v_y\\v_z\\1\\\end{matrix}\right]=\left[\begin{matrix}\frac{d}{a}\cdot v_x\\d\cdot v_y\\?\\-v_z\\\end{matrix}\right]

이제 저 ?? 값을 유도하는 방법은 아래에서 계속됩니다.

1-2. 상용 엔진의 NDC 공간은 어떤 형태인가요?

상용엔진들의 NDC 공간을 알아보기 전에
로컬, 월드, 뷰공간을 어떤 좌표계를 쓰는지는 생각보다 간단한 검색을 통해 알아낼 수 있습니다.
(자체 엔진도 슬쩍 끼웠습니다...ㅎㅎ;;)

  • 유니티 엔진 (SRP 문서에서 참조)
    • 로컬, 월드, 뷰공간 : 왼손 좌표계
  • 언리얼 엔진
    • 로컬, 월드, 뷰공간 : 왼손 좌표계
  • 자체 엔진
    • 로컬, 월드, 뷰공간 : 오른손 좌표계
    • NDC 공간 : 왼손 좌표계

그러나 NDC관련해서는 알기가 정말 어렵습니다..!
그래서 저는 저 나름대로의 뇌피셜을 통해 판단을 해보기로 했습니다.

💡 깊이맵 테스트를 위한 선형컬러 출력방식을 vec4(depthValue, depthValue, depthValue, 1.0)으로 설정을 했다는 전제하에 작성한 점 참고 바랍니다!

가까울수록 0, 멀수록 1인 경우가까울수록 1, 멀수록 0인 경우

깊이값을 보기 위해선 그냥 렌더링이 불가능하기 때문에 깊이값만 들고와서 해당 포지션에 깊이값에 따른 선형 컬러값을 넣습니다.

이 때 위에 작성한 전제조건 하에 컬러값은 zz깊이에 비례하게 작동하게 됩니다.

따라서 점점 하얀색으로 나타난다면 1에 가까워진다는 뜻이므로 zz값이 커지고
점점 검은색으로 나타난다면 0에 가까워진다는 뜻으로 zz값이 작아진다고 보게 되었습니다.

맞는지는 잘 모르겠지만 어쨌든 이러한 논리를 통해 상용엔진들의 NDC 좌표계를 유추해보았습니다!

유니티 URP언리얼 4

유니티의 경우엔 좀 다양하게 사용되는 것을 확인하였습니다.
하지만 멀수록 어두워지는 깊이 버퍼의 대부분 쉐이더 코드를 보면 코드 상에서 강제로 깊이값을 뒤집는 부분을 볼 수 있었습니다.

언리얼의 경우 DBuffer 자체적으로 reverse-Z를 사용한다는 게시글을 보았습니다. 이렇게 만들어진 이유는 깊이값에 따라 더 나은 정밀도를 표현하여 z-fighting 문제를 어느정도 줄일 수 있다고 작성되었습니다.
(출처 : https://interplayoflight.wordpress.com/2017/10/25/how-unreal-renders-a-frame/)

결론적으로 아래와 같이 정리를 하였습니다.

  • 유니티 : 버퍼 자체는 왼손좌표계, 그러나 실제로 응용할 땐 뒤집어 사용 → 오른손 좌표계
  • 언리얼 : DBuffer 자체적으로 기존(왼손) 좌표계에서 reverse-Z를 사용 → 오른손 좌표계

그리고 제가 제작하는 엔진은 그냥 알아서 NDC 공간이 한번 뒤집혔기 때문에 왼손 좌표계를 사용하게 됩니다..ㅎㅎ

수학적으로 굳이 zz값을 뒤집어 쓰는 이유가 있을까요?


위 그래프는 나중에 다시 알아볼 그래프이지만 먼저 알아보도록 하겠습니다.
깊이값은 사실 상 위 그림처럼 log\log함수의 형태를 띄고 있습니다. 그래프를 보면 알 수 있듯이 표현할 수 있는 범위가 zz값(그림 상에선 xx값)이 가까울 수록 넓고, 멀어질 수록 좁아집니다.

여기서 카메라와 가까운 물체는 굳이 많은 데이터가 없어도 어느정도 깊이값에 대한 오차가 먼거리 보다 상대적으로 적기 때문에
깊이값의 표현 범위가 상대적으로 적은 먼거리의 깊이값 표현 정확도를 높이기 위해 뒤집는 것으로 볼 수 있습니다.

1-3. NDC 공간의 깊이 범위는 어떻게 되나요?

NDC가 깊이값을 가지기 위해선 투영행렬에서 zz값이 필요하다는 것을 알 수 있습니다.
근데 깊이값이 무엇에 따라 얼마만큼 적용되는걸까요?

사실 깊이값의 범위는 렌더링 라이브러리에 따라 다릅니다!

[OpenGL] 1-1~11의 범위

[1,1][-1, 1]범위는 OpenGL에서 사용하는 깊이버퍼 범위입니다.
그럼 해당 범위를 NDC 범위로 지정하고 투영행렬을 구해봅시다!

일단 이전에 배웠던 NDC 2차원에서의 행렬과 비슷한 느낌으로 근평면과 원평면을 따로 계산해줍니다.

먼저 근평면을 기준으로 알아봅시다.
뷰공간에서의 zz축은 뒤집힌 상태니 근평면 뷰좌표는 v=(0,0,n,1)v = (0, 0, -n, 1) 로 적용할 수 있습니다.
그리고 클립좌표는 이전 편에서 다뤘던 것처럼 Clip=(davx,dvy,?,vz)Clip = (\frac{d}{a} \cdot v_x, d \cdot v_y, ?, -v_z)를 적용하여 클립좌표를 아래와 같이 표현할 수 있습니다.

Clipnear=(0,0,?,n)Clip_{near} = (0, 0, ?, n)

이제 NDC 행렬로부터 저 ? 구간을 구하기 위해선 NDC좌표와 뷰좌표를 곱해주면 됩니다.
그럼 최종적인 클립좌표는 (0,0,1n,n)(0, 0, -1\cdot n, -n)이 됩니다.

이를 미리 구해놨던 투영 행렬에 대입을 하면 아래와 같이 표현할 수 있습니다.

Pn=[da0000d0000kl0010][00n1]=[00nn]P_n=\left[\begin{matrix}\frac{d}{a}&0&0&0\\0&d&0&0\\0&0&k&l\\0&0&-1&0\\\end{matrix}\right]\left[\begin{matrix}0\\0\\-n\\1\\\end{matrix}\right]=\left[\begin{matrix}0\\0\\-n\\n\\\end{matrix}\right]

원평면도 같은 원리로 행렬을 유도할 수 있습니다.
위와 같이 식을 풀어본다면 뷰좌표는 v=(0,0,f,1)v = (0, 0, -f, 1) 이고, 클립좌표는 (0,0,1f,f)(0, 0, 1\cdot f, f)이 됩니다.

이 역시 투영행렬에 대입하면 아래와 같이 표현이 가능합니다.

Pf=[da0000d0000kl0010][00f1]=[00ff]P_f=\left[\begin{matrix}\frac{d}{a}&0&0&0\\0&d&0&0\\0&0&k&l\\0&0&-1&0\\\end{matrix}\right]\left[\begin{matrix}0\\0\\-f\\1\\\end{matrix}\right]=\left[\begin{matrix}0\\0\\f\\f\\\end{matrix}\right]

이제 여기서 각자의 zz행을 합치기 위해 연립방정식으로 풀어서 k,lk, l을 구합니다.

kn+l=nkf+l=f-kn + l = -n \\ -kf + l = f
kkll
kn+kf=nfk(nf)=nfk=n+fnf-kn+kf = -n - f \\-k(n - f) = -n - f \\k = \frac{n+f}{n-f}l=n+nn+fnfl=n(nf)+n(n+f)nfl=2nfnfl = -n + n \cdot \frac{n+f}{n-f} \\l = \frac{-n(n-f) + n(n+f)}{n-f} \\l = \frac{2nf}{n-f}

이걸 행렬의 k,lk, l에 적용해줍시다.

P=[da0000d0000n+fnf2nfnf0010]P=\left[\begin{matrix}\frac{d}{a}&0&0&0\\0&d&0&0\\0&0&\frac{n+f}{n-f}&\frac{2nf}{n-f}\\0&0&-1&0\\\end{matrix}\right]

[DirectX] 00~11의 범위

왜 DirectX 혼자서 [0, 1]로 되어있나요?
아무래도 깊이값을 선형으로 표현할 때 0~1로 표현하는 것이 더 편리성을 추구할 수 있기 때문인 것으로 알고있습니다 😅
이와 같은 논지로 행렬도 열기준행렬이 아닌 행기준행렬을 사용하였다고 합니다!

3차원 NDC 공간을 투영 행렬로 유도하는 식은 위에서 이미 다 알아봤으니 간단하게 진행해보도록 하겠습니다.

먼저 근평면부터 구해보겠습니다.
뷰좌표는 v=(0,0,n,1)v = (0, 0, -n, 1) 이고, 클립좌표는 (0,0,0n,n)(0, 0, 0\cdot n, n)이 됩니다.

따라서 아래와 같이 표현이 가능합니다.

Pn=[da0000d0000kl0010][00n1]=[000n]P_n=\left[\begin{matrix}\frac{d}{a}&0&0&0\\0&d&0&0\\0&0&k&l\\0&0&-1&0\\\end{matrix}\right]\left[\begin{matrix}0\\0\\-n\\1\\\end{matrix}\right]=\left[\begin{matrix}0\\0\\0\\n\\\end{matrix}\right]

원평면의 뷰좌표는 v=(0,0,f,1)v = (0, 0, -f, 1) 이고, 클립좌표는 (0,0,1f,f)(0, 0, 1\cdot f, f)이 됩니다.
따라서 아래와 같이 표현이 가능합니다.

Pf=[da0000d0000kl0010][00f1]=[00ff]P_f=\left[\begin{matrix}\frac{d}{a}&0&0&0\\0&d&0&0\\0&0&k&l\\0&0&-1&0\\\end{matrix}\right]\left[\begin{matrix}0\\0\\-f\\1\\\end{matrix}\right]=\left[\begin{matrix}0\\0\\f\\f\\\end{matrix}\right]

오.. 위의 PnP_n가 이전에 나왔던 식과 다른 모습입니다. 이제 연립방정식을 통해 k,lk, l을 구합니다.

kn+l=0kf+l=f-kn + l = 0 \\ -kf + l = f
kkll
kn+kf=0fk(nf)=fk=fnf-kn+kf = 0 - f \\-k(n - f) = -f \\k = \frac{f}{n-f}l=0+nfnfl=nfnfl = 0 + n \cdot \frac{f}{n-f} \\l = \frac{nf}{n-f}

이걸 행렬의 k,lk, l에 적용해줍시다.

P=[da0000d0000fnfnfnf0010]P=\left[\begin{matrix}\frac{d}{a}&0&0&0\\0&d&0&0\\0&0&\frac{f}{n-f}&\frac{nf}{n-f}\\0&0&-1&0\\\end{matrix}\right]

오 뭔가 처음 다뤘던 방식보단 공식이 더 깔끔해진 것 같습니다!

2. 깊이값의 활용

깊이값을 통해 위에서 봤던 가까이 있는 물체보다 멀리 있는 물체가 먼저 렌더링 되는 현상을 막을 수 있습니다.
그런데 이걸로 깊이에 대한 활용이 끝일까요...?

2-1. 깊이버퍼 활용 예시

깊이버퍼깊이버퍼를 활용한 DOF 효과

저희는 지금까지 배웠던 이러한 깊이값을 이용해 깊이 버퍼라는 3차원 프레임버퍼를 만들 수 있습니다.

깊이버퍼를 이용하여 블러없는 렌더버퍼와 블러를 씌운 렌더버퍼를 서로 lerp하며 그려주면 로직은 간단하면서도 엄청난 효과를 주는 (성능은 노코멘트 하겠습니다..ㅎㅎ) DOF효과를 얻을 수 있습니다!

이 이외에도 Ambient occlusion, Shadow mapping, Global illumination 등 특히 포스트 프로세싱 단계에서 많은 활약을 할 수 있습니다.

DOF 이미지 출처

2-2. 깊이버퍼의 문제점?

앞서 먼저 깊이값의 형태는 log\log함수의 형태라고 했던 것 기억하시나요?
이렇게 비선형인 문제 탓에 생기는 대표적인 문제점 중 하나인 Z-Fighting 현상은 아래처럼 보여집니다.

위 이미지로 Z-Fighting 현상을 한번에 설명할 수 있습니다ㅋㅋ

보다시피 서로의 깊이값이 비슷비슷하면 부동 소수점으로 온전히 표현하지 못하는 차이로 인해 렌더링이 이상해지는 현상이 발생하는데 마치 서로 싸우는 것 같이 보여 이런 명칭으로 불리고 있습니다.

그럼 어떻게 해결하나요?

안타깝게도 z버퍼 테스트를 활성화 한 상태에서의 완전한 해결법은 없습니다.
대신 아래와 같은 방식으로 어느정도 해결이 가능합니다!

  1. 여러 오브젝트를 서로 겹쳐보이지 않도록 가깝게 두지 않는다.
    단순하지만 성능을 더 요구하지 않는 매우 간단하고 좋은 방식입니다! 대부분 이런 꼼수를 통해 해결합니다.

  2. 깊이버퍼의 정밀도를 높인다.
    그래픽 카드는 깊이버퍼에 대한 품질을 지정할 수 있습니다. 대부분의 깊이버퍼는 24비트의 품질을 가지고 있지만 요즘 그래픽 카드들은 32비트의 품질까진 지원해줍니다.

    하지만 그만큼 더 높은 성능을 요구하고, 결국 정밀도에 한계가 있기 때문에 온전히 해결할 수 없습니다.

  3. 절두체의 범위를 좁힌다.
    근평면과 원평면의 거리를 줄이는 방법입니다. 유한한 범위 내에서 깊이값이 지정되기 때문에 절두체의 양평면 거리를 줄일 수록 깊이버퍼의 정밀도가 올라갑니다. 하지만 현 상황에 맞는 절두체의 최적의 범위를 찾아야합니다.

Z-Fighting 말고 또 다른 예시가 궁금해요!

그림자 여드름 현상의 이론실제로 렌더링 된 그림자 여드름 현상

광원이 표면을 향한 각도가 다양하게 나타나면 깊이 버퍼도 그 각도에서 렌더링이 진행하게 되는데, 이 때 깊이버퍼의 정밀도가 떨어지면 위와 같이 깊이버퍼의 픽셀 단위만큼 거뭇거뭇한 영역이 생깁니다.

게다가 높은 해상도가 제한된 쉐도우 맵핑에선 거의 피해갈 수 없는 버그 중 하나입니다...
이를 그림자 여드름(shadow acne)현상이라고 합니다!

관련하여 더 알고 싶은 분들은 관련 링크를 통해 한번 알아보는 것도 좋은 것 같습니다!


지금까지 원근투영에 대한 보충설명과 깊이맵의 활용에 대해 알아보았습니다.
다음 편에선 원근투영에 대한 심화된 내용 하나를 알아보도록 하겠습니다!

profile
(게임 엔진 프로그래머가 되고싶은) 게임 클라이언트 프로그래머

0개의 댓글