이전에 Occlusion에 대해 다룰 때(6. backface_culling 참고) 우리는 한 가지 문제를 해결하지 못한 채 남겨두었다.
바로 여러 3D모델들이 서로를 가리지 못하는 현상이었는데, 위 사진처럼 오브젝트가 겹칠 때 제대로 가려지지 않는 모습을 볼 수 있다.
이를 해결하기 위한 가장 단순한 방법이 Painter's algorithm인데, 단순히 각 폴리곤들마다 z값 하나를 부여해 그 z값 순으로 정렬 후 뒤에서부터 그려내는 방식이다.
그러나 이 방법으로는 오브젝트들이 서로 겹쳐있지 않은 경우에는 해결되지만, 오브젝트들이 겹치게 되는 순간 해결할 수 없게 된다.
고로 이 문제를 완전히 해결하기 위해서는 우리는 폴리곤보다 더 작은 단위, 즉 픽셀 단위로 z값을 구해 어떤 픽셀에 무얼 그려야 하는지를 결정할 수 있다.
이를 Z-buffering 이라고 한다.
z-buffer는 화면 전체의 픽셀들에 대해 그 픽셀에 위치하는 폴리곤의 z값을 저장하는 행렬이다.
초기값은 Infinity로 초기화되며, 어떤 폴리곤을 어떤 픽셀 위에 그릴 지를 결정할 때 사용된다.
그 픽셀 위치에 내 z값보다 더 작은 z값이 들어있다면 이미 그 자리에 나보다 더 카메라와 가까운 폴리곤이 픽셀을 차지하고 있다는 뜻이므로 그리지 않고,
반대로 내 z값이 그 픽셀 위치에 저장된 z값보다 작다면 그 z값을 overwrite한 뒤 내 폴리곤의 점을 Draw()한다.
(초기화를 Infinity로 하는 이유도 이 때문이다.)
코드와 함께 살펴보자.
//ZBuffer.h
#pragma once
#include <limits>
#include <cassert>
class ZBuffer
{
public:
ZBuffer( int width,int height )
:
width( width ),
height( height ),
pBuffer( new float[width*height] )
{}
~ZBuffer()
{
delete[] pBuffer;
pBuffer = nullptr;
}
ZBuffer( const ZBuffer& ) = delete;
ZBuffer& operator=( const ZBuffer& ) = delete;
void Clear()
{
const int nDepths = width * height;
for( int i = 0; i < nDepths; i++ )
{
pBuffer[i] = std::numeric_limits<float>::infinity();
}
}
float& At( int x,int y )
{
assert( x >= 0 );
assert( x < width );
assert( y >= 0 );
assert( y < height );
return pBuffer[y * width + x];
}
const float& At( int x,int y ) const // const Ref꼴의 zbuffer도 읽어올 수 있게 해주는 함수. 기능자체는 위와 동일.
{
return const_cast<ZBuffer*>(this)->At( x,y );
}
bool TestAndSet( int x,int y,float depth )// 값을 비교하고 필요 시 overwrite
{
float& depthInBuffer = At( x,y );
if( depth < depthInBuffer )
{
depthInBuffer = depth;
return true;
}
return false;
}
private:
int width;
int height;
float* pBuffer = nullptr;
};
Pipeline 생성시 zbuffer를 입력으로 받는다.
//Pipeline.h
// prestep scanline interpolant
iLine += diLine * (float( xStart ) + 0.5f - itEdge0.pos.x);
for( int x = xStart; x < xEnd; x++,iLine += diLine )
{
// recover interpolated z from interpolated 1/z
const float z = 1.0f / iLine.pos.z;
// do z rejection / update of z buffer
// skip shading step if z rejected (early z)
if( zb.TestAndSet( x,y,z ) ) // zbuffer 확인 후 Draw할지 결정
{
const auto attr = iLine * z;
gfx.PutPixel( x,y,effect.ps( attr ) );
}
}
}
}
흥미로운 것은, zbuffer를 사용함으로써 전보다 오히려 성능 개선이 발생할 수 도 있다는 점이다.
모든 점들을 싹다 그리는 것이 아닌, 필요한 점들만 그리는 것이기 때문이다.
더 성능개선을 하기 위해서는 폴리곤(혹은 픽셀)들을 z가 작은 것부터 큰 순으로 정렬하고 앞에서부터 그리는 방식이 있다. 심지어 이때 정렬은 대충 해줘도 되는데, 어차피 zbuffer를 통해 엄격한 occlusion이 처리되기 때문이다.
앞에서부터 그리는 이유는 그래야 셰이더 연산 및 Draw()를 해줄 일이 더 줄어들기 때문이다. (이미 앞에 가로막는 애가 있으면 굳이 셰이더 연산 및 Draw()를 안해줘도 되는데, 앞에서부터 그리게 되면 가로막는 횟수가 증가한다.)
하드웨어의 경우 z-buffer는 z를 저장하는 게 아닌, 1/z를 저장한다. 이는 z-buffer에 약간의 오차를 발생시키지만, 엔비디아 등은 성능을 위해 이를 사용한다.
(관련 자료: https://developer.nvidia.com/content/depth-precision-visualized)
한가지 언급할 만한 점은, 만약 translucent한 픽셀을 그리려면 지금의 정보만으로는 불가능하다는 점이다. translucent할 경우 어떤 픽셀을 그릴 때 바로 뒤의 정보만 가지고서는 제대로 그려낼 수 없기 때문이다. 위 사진철머 투명한 유리가 삼중으로 겹쳐 있을 경우 빨간 유리를 그릴 때는 z-buffer에 저장되어있는 초록유리의 z값 이외에도 파란유리에 대한 정보 또한 저장되어 있어야 한다.
그렇지 않다면 파란유리가 그려지지 못할 것이다. 이는 현대의 그래픽스 라이브러리도 해결을 시도하고 있는 분야이다.
또 한가지 언급할 만한 부분으로, Stencil buffer가 있다. 이는 z-buffer과 함께 자주 사용되며, zbuffer처럼 각 픽셀마다 값이 저장되는 형태이다.
HUD나 거울효과 등을 구현할 때 사용되는 범용적인 버퍼이다. 관련된 내용은 나중에 다뤄보겠다.
'computer graphics > 3D Graphics' 카테고리의 다른 글
[3DGraphics] 13. Geometry shader (0) | 2022.07.24 |
---|---|
[3DGraphics] 12. Vertex shader (0) | 2022.07.24 |
[3DGraphics] 10. Perspective correction (0) | 2022.07.15 |
[3DGraphics] 9. Pixel shader (0) | 2022.07.15 |
[3DGraphics] 8. 3D Pipeline (0) | 2022.07.15 |