본문 바로가기

Graphics , Rendering

디퍼드 렌더링, 그리고 포워드 렌더링과 결합하기

https://learnopengl.com/Advanced-Lighting/Deferred-Shading

 

LearnOpenGL - Deferred Shading

Deferred Shading Advanced-Lighting/Deferred-Shading The way we did lighting so far was called forward rendering or forward shading. A straightforward approach where we render an object and light it according to all light sources in a scene. We do this for

learnopengl.com

위 내용을 의역한 것입니다. 일부는 생략하였습니다.

 

포워드 렌더링

지금까지의 렌더링 방식. 모든 오브젝트들에 대해 개별적으로 라이트 계산을 수행한다.

각각의 렌더링되는 오브젝트들의 픽셀이 각각의 라이트에 대해 반응해야 하므로 느리다.

  • 같은 스크린 픽셀에 여러 개의 오브젝트들이 그려질 때처럼, 깊이 정보가 복잡할수록 fragment shader가 낭비된다 - 픽셀 셰이더의 계산 결과가 overwrite 되기 때문.

디퍼드 셰이딩 (Deferred Rendering) 은 많은 라이트가 있는 장면을 획기적으로 최적화 할 수 있는 몇 가지 새로운 옵션을 제공한다 - 수백, 수천 개의 오브젝트들을 상식적인 framerate로 렌더링 할 수 있다.

디퍼드 셰이딩은 라이팅 같은 무거운 연산틀을 니중 단계로 미룬다(deferred) 는 아이디어에 기반한 것이다. 디퍼드 렌더링은 크게 두 가지 패스로 이루어져 있는데, 첫 번재 패스는 Gemoetry pass이다. 일단 장면을 렌더링 해서 모든 지오메트리 정보를 G-buffer라는 텍스처 집합에 저장한다 : 이 정보에는 위치 벡터, 노멀 벡터, (재질의)스페큘러 값이 포함된다. 이렇게 저장된 정보들은 이후 라이팅 계산 단계에서 사용된다. 아래는 프레임 1개의 G-Buffer모음을 시각화한 것이다.

 

… 중간 생략. https://www.rastertek.com/dx11tut50.html 서론 부분과 내용 겹침.

 

 

Deferred 렌더링과 Forward 렌더링 결합하기

색상이 있는 빛을 발광하는 광원을 3D 큐브로 각 광원의 위치에 렌더링 하고 싶다고 하자. 가장 먼저 떠오러는 아이디어는 단순히 모든 광원들을 디퍼드 셰이딩 파이프라인 맨 마지막에 디퍼드 라이팅 쿼드 위에 포워드 렌더링 하는 것이다. 따라서 큐브를 원래 우리가 하던 대로 렌더링 하되, 디퍼드 레넏링 작업이 끝난 뒤에 한다. 코드로 표현하자면 아래와 같다.

 

// deferred lighting pass
[...]
RenderQuad();
  
// now render all light cubes with forward rendering as we'd normally do
shaderLightBox.use();
shaderLightBox.setMat4("projection", projection);
shaderLightBox.setMat4("view", view);
for (unsigned int i = 0; i < lightPositions.size(); i++)
{
    model = glm::mat4(1.0f);
    model = glm::translate(model, lightPositions[i]);
    model = glm::scale(model, glm::vec3(0.25f));
    shaderLightBox.setMat4("model", model);
    shaderLightBox.setVec3("lightColor", lightColors[i]);
    RenderCube();
}

 

그런데, 이 렌더링 된 큐브들은 디퍼드 렌더러에 저장된 지오메트리 depth 정보를 전혀 교러하지 않았기 때문에, 항상 그 전에 렌더링 된 오브젝트들의 위에 렌더링 된다. 이것은 우리가 원하는 결과가 아니다.

 

 

우리가 할 일은, 먼저 지오메트리 패스의 depth 정보를 기본 프레임 버퍼의 depth 버퍼로 복사하고, 라이트 큐브만 렌더링 하는 것이다. 이렇게 하면, 라이트 큐브의 픽셀이 그 전에 렌더링 된 지오메트리의 위에만 그려지게 된다.

프레임버퍼의 내용을 다른 프래임버퍼로 복사할 때, glBlitFramebuffer 함수의 도움을 받을 수 있다.

(이 함수는 anti-aliasing 챕터에서 multisampled 된 프레임 버퍼를 resolve 하는 데도 사용되었다)

- 참고 : https://learnopengl.com/Advanced-OpenGL/Anti-Aliasing

glBlitFramebuffer 함수는 프레임 버퍼에서 사용자가 지정한 여역을 카피해서, 또다른 프레임 버퍼의 사용자가 지정한 영역으로 복사할 수 있다.

deferred geometry pass에서 렌더링 된 오브젝트들의 깊이를 gBuffer FBO에 저장하였다. 만약 이 내용을 기본 프레임버퍼의 depth buffer에 카피해 오려고 한다면, 마치 장면의 모든 지오메트리가 포워드 렌더링 된 것처럼 라이트 큐브가 렌더링 될 수 있을 것이다. anti-aliasing 챕터에서 간단히 설명한 것처럼, 읽어 올 프레임버퍼를 지정하고, 기록할 프레임버퍼를 지정하면 된다.

 

glBindFramebuffer(GL_READ_FRAMEBUFFER, gBuffer);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0); // write to default framebuffer
glBlitFramebuffer(
  0, 0, SCR_WIDTH, SCR_HEIGHT, 0, 0, SCR_WIDTH, SCR_HEIGHT, GL_DEPTH_BUFFER_BIT, GL_NEAREST
);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
// now render light cubes as before
[...]

여기에서, 프레임버퍼의 depth 버퍼 내용 전체를 기본 프레임버퍼의 depth buffer로 복사해 왔다. 이 작업은 color 버퍼나 스텐실 버퍼에도 비슷하게 적용될 수 있다. 이제 라이트 큐브를 렌더하면, 라이트 큐브가 장면의 지오메트리에 올바르게 합성된다.

https://learnopengl.com/code_viewer_gh.php?code=src/5.advanced_lighting/8.1.deferred_shading/deferred_shading.cpp 

여기에서 데모 코드 전체를 볼 수 있다.

이 접근법으로, 포워드 셰이딩과 디퍼드 셰이딩을 쉽게 결합할 수 있다. 여전히 블렌딩을 적용할 수 있고, 완전한 디퍼드 렌더링 컨텍스트에서는 불가능한 특수한 셰이더 효과가 필요한 오브젝트들을 렌더링 할 수 있다.

 

라이트가 엄청 많을 때

디퍼드 렌더링을 흔히 수만 개의 광원들을 무거운 퍼포먼스 비용 없이 렌더링 할 수 있는 장점으로 찬양한다. 디퍼드 렌더링 자체로 아주 많은 양의 광원을 렌더링 할 수 있는 것은 아니다 - 여전히 각 프레그먼트의 라이팅 컴포넌트를 장면의 각 광원에 대해 연산해야 하기 때문이다. 디퍼드 렌더링에서 많은 수의 광원을 렌더링 할 수 있는 것은, light volume이라는 아주 간결한 최적화를 적용할 수 있기 때문이다.

일반적으로 라이트가 있는 넓은 장면의 fragment를 렌더링 할 때는, 장면 내의 각 광원에 대해서 fragment와 광원까지의 거리를 고려하지 않고 기여도를 계산한다. 이 라이트들 중 많은 부분은 그 픽셀에 도달하지 않을 것인데, 라이트 연산을 낭비할 필요가 있을까?

light volume 저변의 아이디어는 광원의 radius, 또는 volume - 즉 라이트가 fragment에 도달할 수 있는 영역을 계산하는 것이다. 대부분의 광원들이 어떤 종류의 attenuation을 사용하면, 라이트가 닿을 수 있는 최대 거리를 계산할 때 이것을 사용할 수 있다. fragment가 하나 또는 여러 개의 light volume 안쪽에 있을 때만 비싼 라이트 연산을 수행하는 것이다. 이것은 라이팅이 필요한 곳에만 계산할 수 있어 상당한 양의 연산을 절약할 수 있다.

이 방법의 핵심은 광원의 크기 또는 반지름을 파악하는 것이다.

 

라이트의 볼륨 또는 반지름 계산하기

라이트의 볼륨이나 반지름을 얻기 위해서는 라이트 기여도가 0.0이 언제가 되는지 감쇠 공식(attenuation equation)을 풀면 된다. 감쇠 함수로는 https://learnopengl.com/Lighting/Light-casters 에서 소개한 함수를 사용할 것이다.

Flight가 0.0인 상황에 대해서 이 공식을 풀면 된다. 그런데, 이 공식은 완전히 0.0에 도달하지는 않기 때문에, 완벽한 해법이 아니다. 공식을 0.0에 대해 푸는 대신, 0.0에 가깝지만 여전히 어둠으로 인식되는 brightness 값에 대해 풀 것이다. 5/256 의 밝기 값이 이 챕터의 데모 장면을 위해 적절할 것이다 - 8bit의 기본 프레임버퍼는 각 컴포넌트별로 그 정도의 밝기를 표현할 수 있기 때문이다.

 

감쇠 함수는 그 범위 안에서도 대부분 어둡다. 5/256보다 더 어두운 brightness 값으로 제한하려고 한다면, 범위가 너무 넓어져서 효과가 떨어질 것이다. 유저가 광원 범위 경계에서 갑작스럽게 컷오프 되는 부분을 보지 못하는 한 괜찮다. 물론 이것은 언제나 장면의 종류에 따라 달라진다 - 더 높은 brightness threshold는 더 좁은 범위의 light volume을 만들기 때문에 더 효과적일 것이지만, volume의 경계에서 라이트가 끊어지는 듯한 눈에 띄는 아티팩트가 생길 수 있다.

 

 

우리가 풀어야 할 감쇠 함수는 아래와 같은 모습이 된다.

Imax는 광원 색상의 가장 밝은 컴포넌트가 된다. 라이트 색상의 가장 밝은 컴포넌트를 라이트의 가장 밝은 intensity 값으로 사용하는 것이 가장 이상적인 light volume 반지름을 얻을 수 있다.

공식을 계속 풀어보자.

 

마지막 공식은 x를 계산할 수 있는 일반적인 공식이다 - 즉 Light Volume의 반지름 Constant, Linear 및 Quadractic 파라미터가 주어진 경우 빛의 볼륨 반경을 구할 수 있다.

float constant  = 1.0; 
float linear    = 0.7;
float quadratic = 1.8;
float lightMax  = std::fmaxf(std::fmaxf(lightColor.r, lightColor.g), lightColor.b);
float radius    = 
  (-linear +  std::sqrtf(linear * linear - 4 * quadratic * (constant - (256.0 / 5.0) * lightMax))) 
  / (2 * quadratic);

각 광원에 대해 이 반지름을 계산해서, fragment가 광원의 volume 안에 있는 경우에만 라이팅을 계산할 것이다. 아래는 그렇게 계산된 light vbolume들을 고려한, 업데이트된 라이트 패스이다. 이 접근법은 교육을 위한 것이며, 앞으로 언급하듯 실제 셋팅에서 실용적이지는 않다.

 

struct Light {
    [...]
    float Radius;
}; 
  
void main()
{
    [...]
    for(int i = 0; i < NR_LIGHTS; ++i)
    {
        // calculate distance between light source and current fragment
        float distance = length(lights[i].Position - FragPos);
        if(distance < lights[i].Radius)
        {
            // do expensive lighting
            [...]
        }
    }   
}

결과는 이전과 완전히 똑같지만, 이번에는 각 광원의 volume이 차지하는 부분만 라이팅이 계산된다.

여기에서 최종 소스 코드를 볼 수 있다.

 

light volume을 실제 사용하는 방법

위에서 살펴본 fragment 셰이더는 실제로는 제대로 동작하지 않고, 라이트 연산을 줄이는 데 light volume을 대략 어떻게 사용할 수 있는지 설명할 뿐이다. 사실, GPU와 GLSL이 반복문과 분기문을 최적화하는 데 매우 취약하다. 그 이유는 GPU상에서의 셰이더 실행이 매우 병렬적이라 대부분의 아키텍처들이 효율적으로 동작하기 위해 완전히 같은 셰이더 코드를 수행하는 넓은 쓰레드 묶음을 실행해야 하기 때문이다. 이것은 (대부분의 경우) 셰이더가 해당 그룹의 쓰레드가 모두 같은 내용을 수행하는 것을 확실시 하기 위해, if문의 모든 분기를 실행해야 하는 것을 의마한다. 이렇게 되면 이전에 radius check 최적화는 완전히 쓸모없게 되어버린다 - 즉, 여전히 모든 광원에 대해 라이팅을 계산해하게 될 것이다.

light volume을 사용하는 올바른 접근법은, light volume 반경 만큼 스케일링된 실제 sphere를 렌더링하는 것이다. 이 sphere들의 중심은 광원의 위치에 있고, light volume 반경 만큼 스케일링 되었기 때문에, sphere는 광원의 가시 볼륨을 정확히 둘러싼다. 이 부분이 트릭의 핵심이다 - sphere를 렌더링 할 때, 디퍼드 라이팅 셰이더를 사용한다. 렌더링 된 sphere광원이 영향을 미치는 픽셀들과 정확히 맞아 떨어지는 셰이더 결과를 보여주기 때문에, 필요한 픽셀들만 렌더링 하고 다른 픽셀들은 건너뛸 수 있다. 아래 이미지는 이를 설명한다.

 

 

이 작업은 장면 내의 모든 라이트에 대해 수행되고, 결과 fragment들은 모두 더해져 블렌딩된다. 이 결과는 이전과 정확히 같은 결과이지만, 각 광원에 대해 연관된 fragment들만을 렌더링한다. 이것은

nr_objects * nr_lights 에서 nr_objects + nr_lights 로 효과적으로 연산을 줄이고, 많은 라이트가 있는 장면을 아주 효율적으로 렌더링하게 된다. 이 접근법이 디퍼드 렌더링이 수많은 라이트들을 렌더링하는 데 적합하도록 만드는 것이다.

그러나 이 접근법에는 여전히 한계가 있다 : face culling 이 활성화 되어 있어야 한다(그렇지 않으면 라이트 효과를 두 번 렌더링해야 할 것이다) 그리고 face culling이 활성화 되어 있을 경우, 광원의 volume이 back-face culling 에 의해 더 이상 렌더되지 않은 이후에 유저가 볼륨 안으로 들어갈 수 있다. 이렇게 되면 광원의 영향이 제거된다; sphere의 뒷면만을 렌더링하면 이것을 해결할 수 있다.

라이트 Volume을 렌더링하는 것은 퍼포먼스 비용이 있다. 많은 수의 라이트가 있는 장면을 디퍼드 셰이딩으로 그냥 렌더링 하는 것 보다는 일반적으로 훨씬 빠르지만, 더 최적화 할 수 있는 부분도 있다. 다른 두 가지 인기있는 (그리고 더 효율적인) 디퍼드 쉐이딩을 바탕으로 한 확장 기능은 디퍼드 라이팅과 타일 기반 디퍼드 쉐이딩이 있다. 이 두 가지 방법은 많은 양의 라이트를 더 효과적으로 렌더링 할 수 있을 뿐만 아니라, 비교적 효과적인 MSAA 를 수행할 수 있다.

 

디퍼드 렌더링 vs 포워드 렌더링

Light Volume이 없는 디퍼드 셰이딩은 좋은 최적화 방안이다 - 픽셀이 1번의 fragment shader를 수행하기 때문이다. (반면, 포워드 렌더링은 보통 픽셀 당 여러 번의 Fragment shader를 수행한다). 하지만 디퍼드 렌더링에는 몇 가지 단점이 있다 - 메모리 오버헤드가 심하고, MSAA를 수행할 수 없고, 블렌딩은 여전히 포워드 렌더링으로 처리돼야 한다.

라이트가 많지 않은 작은 규모의 장면이라면, 반드시 빠르지 않고, 디퍼드 렌더링에서 얻는 이득보다 더 심한 오버헤드로 오히려 더 느릴 수도 있다. 하지만 더 복잡한 장면에서는 디퍼드 렌더링이 확실한 최적화 방안이 된다 - 더 발전된 최적화 방안들을 적용하면 더욱 그렇다. 게다가, 어떤 렌더링 효과(특히 포스트 프로세스 효과) 들은 디퍼드 렌더러 파이프라인에서 비용이 더 저렴해진다 : 대부분의 Scene input들이 GBuffer에 이미 있기 때문이다.

마지막으로, 포워드 렌더링에서 가능한 모든 효과들은 기본적으로 디퍼드 렌더링에서도 구현될 수 있다는 것을 언급하고 싶다; 이것은 대부분의 경우, 약간의 변환 단계만 거치면 된다. 예를 들어, 노멀 맵핑을 디퍼드 렌더러에서 사용하고 싶다면, 표면 노멀 대신 지오메트리 셰이더 패스 셰이더 단계에서 노멀 맵으로부터 추출한 월드 스페이스 노멀 (TBN 매트릭스 활용) 을 output 하게끔 바꾸면 된다.

라이팅 패스에서의 라이트 계산은 바뀔 필요가 없다. 만약 parallex 맵핑을 적용하고 싶다면, 오브젝트의 Diffuse, Specular, Normal 텍스처를 샘플링 하기 전에 먼저 texture coordinate를 지오메트리 패스에서 displace 하면 된다. 일단 디퍼드 렌더링의 아이디어를 이해하게 된다면, 창의적인 방법을 생각해 내는 것은 그리 어렵지 않다.

 

더 읽을거리

OpenGL Step by Step - OpenGL Development

https://www.intel.com/content/dam/develop/external/us/en/documents/lauritzen-deferred-shading-siggraph-2010-181241.pdf