본문 바로가기
그래픽스

스텐실 버퍼를 사용한 외곽선효과 및 삼각형으로 풀스크린 그리기

by greenherb 2021. 8. 16.

3D 피킹을 구현하고 선택한 오브젝트가 메쉬가있을경우 시각적으로 선택되었다는 효과를 주고싶었습니다.

그래서 오브젝트에 외곽선효과를 주는 방법에 대해서 생각해보았는데요. 마침 LearnOpengl 에 스텐실버퍼를 사용하여 외곽선 효과를 만드는 강좌가 있었습니다.

LearnOpengl에서 설명하는 스텐실 버퍼 외곽선의 원리는 간단합니다.  처음에 스텐실상태를 모든값을 기록하도록 바꾼후 1의 값을 기록하도록 지정합니다. 외곽선을 줄 오브젝트를 그립니다. 이럴경우 스텐실버퍼의 해당 오브젝트 위치에는 1의값이 그려지게됩니다. 예를들어 사각형을 그린다면 현재 보이는 화면버퍼가 0의 값으로 초기화되었다면 1로이루어진 사각형이 보이겠죠? 그후 외곽선을 그릴때 색깔만 그리는 쉐이더를 바인딩하고 기존의 오브젝트보다 살짝큰 오브젝트를 그립니다. 이때 스텐실상태는 1.0의 값이 아닌곳으로 상태를지정합니다 이럴경우 1의 값이아닌곳에 기록을 진행하기때문에 기존 오브젝트영역은 안그려지고 살짝커진부분만그려 외곽선이 생기는 원리입니다.

하지만 이 방법을보고 비효율적이라고 생각했습니다. 만약 그려야할 오브젝트의 메쉬가 상당히 많을경우? 많은 메쉬를 두번 그려야하고 드로우콜도 2번이나 일어나는것이죠 그것뿐만아니라 보통 외곽선효과는 하나의 오브젝트에만 적용하지않고 상용엔진에서는 여러 엔티티의 모음에 적용하게됩니다. 이럴경우 위 방법은 엔티티 갯수 x 2 의 드로우콜이 발생하겠죠.

상당히 별로인것같습니다. 그래서 생각한방법은 외곽선 검출을 사용하여 외곽선을 그리는것입니다. 하지만 보통 우리가 알고있는 외곽선검출은 현재 이미지에서 외곽선을 검출하는것인데 어떻게 원하는 오브젝트만 검출할까요? 또한 어떻게 원하는 색상으로 검출할까요?

일단 진행과정은 아래와 같습니다. 

첫번째로 원하는 오브젝트들을 스텐실버퍼에 원하는값으로 기록합니다. 이때 한꺼번에 렌더링하면서 컬러,깊이,스텐실을 기록합니다.

두번째로 기록한 스텐실버퍼를 가지고 풀스크린쿼드를 배경색과 확실히다른 색상으로 텍스쳐를 그립니다. 이때 오브젝트들이 기록한 값에 색상을 그려야합니다.

세번째로 위에서만든 텍스쳐로 외곽선검출을 진행합니다. 그리고 검출된 외곽선을 오브젝트를 제외한 나머지부분에 기록합니다.

첫번째로 쉐이딩과정에서 컬러뿐만아니라 스텐실도 같이 기록해줍니다. 따로 렌더링하기에는 한번에 할 수 있기때문이죠. 다만 렌더링전에 선택된 오브젝트와 선택되지않은 오브젝트를 구별해줘야합니다. 선택된 오브젝트를 렌더링할 때는 스텐실 상태를 변경해줘야하기 때문이죠. (추후에 투명한 물체를 그릴때 순서를 정해줘야하는것처럼 렌더큐시스템을 만들어두시면 좋습니다.)

컬러값을 기록하고 또한 스텐실값도 기록한다면 깊이버퍼가 깊이-스텐실포멧이라면 깊이와 스텐실값 둘다 기록되게됩니다. 일반적으로 스텐실버퍼를 위해 8bit를 사용하므로 0~255까지의 값을 기록할 수 있겠네요. 예를들어 원형구를 스텐실값 1로 기록하게되면 

이런식으로 스텐실버퍼에 기록되게 될것입니다.  이렇게 기록된 스텐실버퍼를 가지고 해당부분만 렌더링하도록하면 어떻게 보이게될까요?

위에서 그린 그림이 아래그림과 같다고 생각하시면됩니다. 검은부분은 0의 스텐실값이 있고 원형이있는부분의 스텐실값은 1입니다. 그리고 스텐실상태에서 EQUAL 키워드로 1.0의 값과 같은부분일경우만 스텐실판정에서 통과하도록합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if (!outlineObjects.empty())
{
    RenderCommand::SetStencilTest(true);
    RenderCommand::SetFrontStencilFunc(COMPARISON_FUNC::ALWAYS, 1.0);
    RenderCommand::SetFrontStencilOp(STENCIL_OP::KEEP, STENCIL_OP::REPLACE, STENCIL_OP::REPLACE);
    RenderCommand::SetStencilWriteMask(0xff);
    for (int i = 0; i < outlineObjects.size(); ++i)
    {
        auto entity = scene->GetEntityById(outlineObjects[i]);
        PBRRendering(entity);
    }
    RenderCommand::SetStencilTest(false);
}
for (int i = 0; i < otherObjects.size(); ++i)
{
    auto entity = scene->GetEntityById(otherObjects[i]);
    PBRRendering(entity);
}
cs

스텐실에 기록하기위한 코드를 보여드리도록 하겠습니다. 첫번째로 외곽선효과가 적용될 오브젝트들을 모아줍니다. 그다음은 스텐실판정을 지정합니다. 이때 저는 렌더링인터페이스로 감싸놓았지만 OpenGL에서는 glEnable(GL_STENCIL_TEST); 로 스텐실 판정을 키시고 DirectX11에서는 깊이스텐실스테이트 객체를 통해 깊이스텐실구조체 D3D11_DEPTH_STENCIL_DESCStencilEnable 변수에서 true값을 넣으셔야합니다. 이때 두 API모두 FrontFace ,BackFace가있는데 이것은 메쉬의 컬링과 관련된 옵션입니다. 우리는 보여지는것을 기록할것이므로 FrontFace입니다.

그다음 스텐실판정함수를 지정해야합니다. ALWAYS ,1.0 을넣었는데 이뜻은 기존 스텐실 버퍼가 어떻든 무조건 판정에 통과하며 값은 1.0으로 지정한다는것입니다. 다만 이 값은 다음에 올 함수에서 설명되는데. 

첫번재 파라매터는 스텐실판정에 실패했을경우 다음은 깊이판정에 실패했을경우 다음은 둘다 통과했을경우의 연산을 지정한것입니다. 일단 스텐실판정은 ALWAYS이므로 실패할경우가없습니다. 다만 깊이판정은 실패할수있겠지요 예를들어 어떤 오브젝트에 가려져서 말입니다. 여기서 Replace는 위에서 지정한 1.0의 값으로 교체한다는뜻입니다. 둘다 통과했을때고 1.0으로 교체합니다. (외곽선은 어떤 오브젝트에 가려져도 뒤에서 보여져야하므로 깊이실패에도 값을 교체하도록합니다)

스텐실 비교함수를 지정하는 키워드는 OpenGLglStencilFuncSeparate(face,func,refValuie,mask) 입니다. 첫번째 파라메터는 GL_FRONT,BACK 중 하나를 고르며 Func 부분은 GL_ALWAYS,GL_LESS등 비교함수를 지정합니다. refValue는 해당값을이용하여 스텐실버퍼에있는 값과 비교함수를 이용하여 비교합니다. mask는 마스킹입니다.

DirectX 의경우 위에서 언급한 깊이스텐실 구조체에서 StencilFunc 변수가있습니다. 마스킹값은 StencilReadMask,StencilWriteMask 두가지가있으니 원하는 값에 기록하시면 됩니다. 그리고 OMSetDepthStencilState()함수에서 두번째 파라메터에 레퍼런스값을 전달하면됩니다.

이런식으로 각 API에 맞게 현재 그리는 오브젝트를 스텐실버퍼에 1로기록하게되면 첫번째로 스텐실버퍼는 준비되었습니다.

다음 렌더패스에서 이전에 기록된 스텐실버퍼를 그대로 가져와 현재 스텐실값이 1인곳에 새로운 색을 칠하여 텍스쳐를 만들어야합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//Outline Stencil making
RenderCommand::SetStencilTest(true);
RenderCommand::SetDepthTest(false);
RenderCommand::SetFrontStencilFunc(COMPARISON_FUNC::EQUAL, 1.0);
RenderCommand::SetFrontStencilOp(STENCIL_OP::KEEP, STENCIL_OP::KEEP, STENCIL_OP::KEEP);
 
//RenderCommand::Set
m_ScreenFrameBuffer->Bind();
m_ScreenFrameBuffer->DetachAll();
m_ScreenFrameBuffer->AttachTexture(m_ScreenTexture, AttachmentType::Color_0, TextureType::Texture2D);
m_ScreenFrameBuffer->Clear();
m_ScreenFrameBuffer->AttachTexture(m_DepthBuffer, AttachmentType::Depth_Stencil, TextureType::Texture2D);
 
m_ScreenShader->Bind();
m_ColorConstantBuffer->Bind(0, Type::Pixel);
Color col;
col.color = glm::vec4(1.0f, 1.0f, 1.0f, 1.0f);
m_ColorConstantBuffer->SetData(&col, sizeof(Color), 0);
TextureLibrary::Load("white1x1tex")->Bind(0);
//m_ColorBuffer->Bind(0);
 
//FullScreen Triangle / Stencil Making done
RenderCommand::Draw(03);
m_ScreenShader->UnBind();
cs

새로운 프레임버퍼에서 컬러가 기록될 텍스쳐와 이전 렌더패스에서 사용한 깊이버퍼를 붙입니다. 주의점은 깊이버퍼를 초기화해서는 안된다는것입니다. 그리기전에 깊이판정은 false로 지정합니다. 깊이버퍼에있는 깊이값에따라 색상이 기록되지않는것을 막기위해서입니다. 스텐실 비교함수로 EQUAL,1.0 즉 1.0의 값과 같은곳은 스텐실판정에 통과하도록합니다. 그리고 비교판정에 성공했을경우 연산은 아무것도 하지않도록 KEEP 으로 지정합니다.

이상태에서 원하는 색상을지정하고 풀스크린쿼드를 그리면됩니다. 근데 여기서 이전 빌보드 렌더링때처럼 같이 정점3개의 드로우을 진행하네요? 풀스크린 쿼드를 그리기위해 정점버퍼없이 그려보도록 하겠습니다. 근데 왜 쿼드면 정점이 4개여야하는데 3개밖에 안될까요? 아래에서 설명하도록 하겠습니다.

삼각형안에 사각형은.. 정사각형이라고 생각해주세요..

만약 3개의 정점을 전달하고 3개의 정점ID가 들어간다고하면 위 방법처럼 3개의 삼각형만으로 풀스크린쿼드를 만들수 있습니다. 각 정점의 좌표가 NDC 공간산의 좌표이고 정점쉐이더에서 픽쉘쉐이더로 넘어갈때 보간이된다는 특성을사용하여 각 삼각형의 꼭지점 부분의 값을 입력하게되면 자연스럽게 보간되어 사각형의 나머지 부분의 좌표도 정확하게 맞게됩니다. (NDC 범위보다 큰 삼각형을 그리면 당연이 사각형처럼 보여지겠죠?) UV 값도 적절하게 바꿔주면 됩니다.

이때 저는 와인딩방향이 시계방향이므로 정점ID가 0,1,2 일때 순서대로 (-1,-1),(-1,3),(3,-1)의 값이 나와야겠네요!

식은 아래와 같습니다.

xPosition = (vertexID / 2 ) * 4.0 - 1.0f;

yPosition = (vertexID % 2) *4.0 - 1.0f;  식을 유추하는것은 어렵지않습니다만 한번씩 대입해보셔요. 그리고 z값은 0으로 하셔야 가장앞에 올것이고 w값은 당연히 NDC에서는 1.0 이겠죠?

그리고 UV 값의경우 GL과DX의 텍스쳐좌표차이로 Y 값부분이 조금다릅니다.

UV.x = (vertexID / 2) *2; UV.y = (vertexID %2) * 2 (만약 다렉의경우 해당 값을 1에 빼주면됩니다)

이런식으로 풀스크린스쿼드를 그릴수있으며 이제 색상을 그릴차례입니다. 스텐실상태가 없었다면 텍스처 전체에 원하는색상으로 채워졌겠지만. 현재 스텐실값이 1.0 인곳만 스텐실판정이 통과하므로 선택한 오브젝트영역에 렌더링이 진행되게됩니다.

저는 흰색으로 그렸으니 선택한오브젝트영역에 그릴경우 위처럼 보이겠네요. 이제 이렇게 원하는 영역이 칠해진 텍스쳐도 준비되었으면 외곽선을 그리고 기존 컬러버퍼에 덫씌울 일만 남았습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
RenderCommand::SetBlend(true);
RenderCommand::SetBlendOp(BlendOp::BLEND_ADD, BlendOp::BLEND_ADD);
RenderCommand::SetBlendFunc(BlendFunc::BLEND_SRC_ALPHA, BlendFunc::BLEND_INV_SRC_ALPHA, BlendFunc::BLEND_ZERO, BlendFunc::BLEND_ONE);
 
// Outline Draw
RenderCommand::SetFrontStencilFunc(COMPARISON_FUNC::NOT_EQUAL, 1.0);
m_OutlineShader->Bind();
m_ScreenFrameBuffer->DetachAll();
m_ScreenFrameBuffer->AttachTexture(m_ColorBuffer, AttachmentType::Color_0, TextureType::Texture2D);
m_ScreenFrameBuffer->AttachTexture(m_DepthBuffer, AttachmentType::Depth_Stencil, TextureType::Texture2D);
 
m_OutlineConstantBuffer->Bind(0,Type::Pixel);
Outline outline;
outline.color = glm::vec4(1.0f, 1.0f, 0.0f, 1.0f);
outline.outlineThickness = 2.0f;
outline.outlineThreshHold = 0.5f;
m_OutlineConstantBuffer->SetData(&outline, sizeof(Outline), 0);
m_ScreenTexture->Bind(0);
 
RenderCommand::Draw(03);
m_OutlineShader->UnBind();
 
m_ScreenFrameBuffer->UnBind();
 
RenderCommand::SetBlend(false);
RenderCommand::SetStencilTest(false);
RenderCommand::SetDepthTest(true);
cs

이제 이전 렌더패스의 컬러버퍼와 깊이버퍼를 다시 가져다씁니다. 여기서도 중요한점은 프레임버퍼에 붙이면서 초기화하지 않는것입니다. 그리고 outline 쉐이더를 실행하는데 이 쉐이더는 Sobel Edge Detection 즉 소벨가장자기검출 알고리즘을 사용합니다. 그래서 외곽선의 두께,ThreshHold,그리고 외곽선색깔을 넘겨줍니다.

그전에 위에서 블렌드상태를 킨것이 중요한데요. 여기서 블렌드 오퍼레이션은 재쳐두고 블렌드 함수에대해서 이야기해야합니다. OpenGL에서는 glBlendFuncSeparatei(index,srcRgb,dstRgb,srcAlpha,dstAlpha) 함수를 통해 DirectX 에서는D3D11_BLEND_DESC 에 값을 지정하여 블렌드상태를 바인딩해야합니다. 구조체의 BlendDesc는 RenderTarget8개의 배열을 가지고있으며 각각의 배열순서가 현재 바인딩된 렌더타겟의 개수입니다. 그리고 이것은 GL에서도 index파라메터가 어떤 버퍼에 적용될지를 정합니다. 

for (int i = 0; i < 8; ++i)
{
   blendDesc.RenderTarget[i].BlendEnable = desc.BlendDesc[i].blendEnable;
   blendDesc.RenderTarget[i].SrcBlend = Utils::BlendFuncToDx(desc.BlendDesc[i].srcBlend);
   blendDesc.RenderTarget[i].DestBlend = Utils::BlendFuncToDx(desc.BlendDesc[i].dstBlend);
   blendDesc.RenderTarget[i].BlendOp = Utils::BlendOpToDx(desc.BlendDesc[i].blendOp);

   blendDesc.RenderTarget[i].SrcBlendAlpha = Utils::BlendFuncToDx(desc.BlendDesc[i].srcBlendAlpha);
   blendDesc.RenderTarget[i].DestBlendAlpha = Utils::BlendFuncToDx(desc.BlendDesc[i].dstBlendAlpha);
   blendDesc.RenderTarget[i].BlendOpAlpha = Utils::BlendOpToDx(desc.BlendDesc[i].blendOpAlpha);
}

대략적으로 다이렉트 블렌드구조체는 위처럼 지정됩니다.  BlendEnable은 해당 렌더타겟의 블렌드를 키고끄고 SrcBlend 와 DestBlend 는 원본 색상과 소스 색상을 결정할 방법을 정하며 BlendOP는 두 색상을 어떻게 연산할지 정해줍니다.

SrcBlendAlpha와 DestBlendAlpha는 위와 같지만 색상이아니라 알파값입니다. 블렌딩할때 알파값을 어떻게 정할지 정해주고 BlendOpAlpha는 두 알파값을 어떻게 연산한지 정해줍니다.

위 코드에서는 소스색상은 소스의 알파값으로 정하고 원본색상은 1을 소스알파값으로 뺀 값으로 지정합니다. 이경우 소스색상의 알파값이 1.0 일경우 원본색상은 0이되어버립니다. (알파가 0인경우를 생각하면된다) 여기까지의 연산은 알파값에 아무런 영향을 주지않습니다. 다음 두파라매터는 ZERO와 ONE 이것은 소스 색상의 알파값을 정할때 ZERO로 두어 소스알파값은 0으로 두고 원본색상의 알파값은 ONE으로 즉 1로둔다는것입니다. 이렇게하면 기존 컬러버퍼의 원본색상의 알파값이 수정되지않고 그대로 유지되게됩니다. 왜 이런 블렌드 상태를 지정해야하는지는 Sobel 쉐이더 코드를 설명하면서 알려드리도록 하겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
cbuffer Color : register(b0)
{
    float4 outlineColor;
    float outlineThickness;
    float outlineThreshHold;
}
struct PSIn
{
    float2 uv  : TEXTURE;
    float4 pos : SV_POSITION;
};
Texture2D image : register(t0);
SamplerState imagesplr : register(s0);
 
static float xFilter[9= { -1,0,1,-2,0,2,-1,0,1 };
static float yFilter[9= { 1,2,1,0,0,0,-1,-2,-1 };
 
struct PS_Out
{
    float4 color : SV_TARGET0;
};
PS_Out PSMain(PSIn Input)
{
    float width, height;
    image.GetDimensions(width, height);
 
    float2 tx = float2(outlineThickness / width, 0.0);
    float2 ty = float2(0.0,outlineThickness / height );
 
    float grid[9];
    float3 greyScale = float3(0.2990.5870.114);
 
    grid[0= length(dot(image.Sample(imagesplr, Input.uv -tx + ty).xyz, greyScale));
    grid[1= length(dot(image.Sample(imagesplr, Input.uv + ty).xyz, greyScale));
    grid[2= length(dot(image.Sample(imagesplr, Input.uv + tx + ty).xyz, greyScale));
    grid[3= length(dot(image.Sample(imagesplr, Input.uv - tx).xyz, greyScale));
    grid[4= length(dot(image.Sample(imagesplr, Input.uv).xyz, greyScale));
    grid[5= length(dot(image.Sample(imagesplr, Input.uv + tx).xyz, greyScale));
    grid[6= length(dot(image.Sample(imagesplr, Input.uv - tx - ty).xyz, greyScale));
    grid[7= length(dot(image.Sample(imagesplr, Input.uv -ty).xyz, greyScale));
    grid[8= length(dot(image.Sample(imagesplr, Input.uv + tx -ty).xyz, greyScale));
    float sx=0.0f, sy=0.0f;
    for (int i = 0; i < 9++i)
    {
        sx += grid[i] * xFilter[i];
        sy += grid[i] * yFilter[i];
    }
    float dist = sqrt(sx * sx + sy * sy);
    float edge = dist > outlineThreshHold ? 1 : 0;
 
    float4 result = outlineColor;
    result.a = result.a * edge;
 
    PS_Out output;
    output.color = result;
    return output;
}
cs

포스트프로세싱을 배우시면서 가장자리 검출효과를 적용해보신적이 있으실겁니다. Sobel 가장자리 검출 알고리즘의 자세한내용은 인터넷에 영상처리에 매우 많이 나와있습니다.  아래링크의 홈페이지에서 확인하셔도 좋습니다.

https://danielilett.com/2019-05-11-tut1-4-smo-edge-detect/

 

Image Effects | Part 4 - Edgy Talk

Calculating image gradients and drawing some edges

danielilett.com

아주 간단하게 설명하자면 두가지 방향으로 나누어 픽셀값이 급격하게 변화했는지를 체크합니다. 첫번째로 왼쪽에서 오른쪽 즉 X방향으로의 픽셀을 검사합니다. 만약 이전값과 다음값과 차이가없다면 값은 0 차이가있다면 그 차이만큼 값이 커지겠죠 이렇게 Y방향으로도 진행합니다.

위 쉐이더 코드에서도 가운데 uv값을 기준으로 주변 8개의 uv값에 대한 텍스쳐샘플링을 진행합니다. 여기서 그레이스케일을 진행하셔서 회색조로 만들어도 좋지만 그냥 해도 무방합니다.그리고 length 함수를 호출하여 색상의 세기를 구합니다. (물론 그냥 float3으로 진행하셔도 무방하지면 연산횟수를 줄이는게 좋겟지요? 우리는 가장자리만 검출하면 되니깐요!)

이제 각 픽셀에대한 색상의 세기를 구했다면 위에서 정의한 필터값을 적용하여 현재 uv값에대한 주변 픽셀의 값변화를 체크합니다. x방향과 y방향의 값변화 (기울기) 를 구하셨으면 수직수평에 독립적인 전체적인 기울기 크기를 각각의 방향의 변화값 제곱 루트를 통하여 구합니다. 만약 이값이 우리가 정한 Threshhold 값보다 클경우 값의 변화가 있는곳이므로 1 의값을 edge에 저장합니다.(ThreshHold값이 올라가면 올라갈수록 픽셀값이 차이가 급격한부분만 가장자리가 검출될것입니다. 우리가 준비한 텍스쳐는 흰색부분과 어두운 회색부분이니 엄청나게 차이가 나겠죠?)

이때 이 edge 변수의 값으로 최종 색의 알파값이 1일지 0일지 정해지는데요. 1이라면 위에서 정한 블렌드상태에서 소스의 색상이 알파값이 1이라면 원본색상을 색상이 0이되어버립니다. 그리고 만약 edge가 0이라 외곽선이 표현되지않는 저멀리의 구간일경우 알파값에대한 함수는 소스는 Zero 이고 원본은 One이므로 dege가 0이어서 소스색상의 알파값이 0이되어도 원본에는 아무런 영향을 미치지않습니다. 

만약 마지막부분의 color 부분은 그냥 float4(dist,dist,dist,1.0f)로 지정할경우. 아래와 같은 일반적인 가장자리 검출 이미지가 나오게됩니다.

자이제 외곽선까지 기존의 컬러버퍼에 기록하게되면 아래와같은 결과가 나오게됩니다.

오브젝트 뒤에 가려도 외곽서는 그려진다.
여러 메쉬가 붙어있어도 외곽선만 그린다.

만약 이전 씬처럼 수백개의 메쉬가 있는 경우라면.. LearnOpenGL 처럼 메쉬를 두번그리는 방식으로하면 성능에 크게 영향을 미치겠죠?

최종적으로 피킹한 오브젝트에 외곽선을 넣었습니다.

여기서 주의점은 위 Outline 쉐이더코드에서 샘플링하는 텍스쳐의 uv에대한 매핑방법은 Clamp 여야합니다. Repeat로 하면 uv범위값을 넘어서서 다시 반대쪽으로 접근하기때문에 외곽선이 화면밖으로 넘어갈시 이상한곳에 생기게됩니다.