Skip to content

프레임버퍼

지금까지 우리는 색상 값을 기록하는 컬러 버퍼, 깊이 정보를 기록하고 테스트하는 깊이 버퍼, 그리고 특정 조건에 따라 특정 프래그먼트를 버릴 수 있도록 하는 스텐실 버퍼 등 여러 종류의 화면 버퍼를 사용해 왔습니다. 이러한 버퍼들의 조합은 GPU 메모리의 어딘가에 저장되며 프레임 버퍼라고 불립니다. OpenGL은 사용자가 직접 프레임 버퍼를 정의할 수 있는 유연성을 제공하며, 따라서 사용자 지정 컬러 버퍼(그리고 선택적으로 깊이 및 스텐실 버퍼)를 정의할 수 있습니다.

지금까지 수행한 렌더링 작업은 모두 기본 프레임버퍼에 연결된 렌더 버퍼를 기반으로 이루어졌습니다. 기본 프레임버퍼는 창을 생성할 때 생성되고 구성됩니다(GLFW가 이 작업을 수행합니다). 자체 프레임버퍼를 생성함으로써 렌더링 대상을 하나 더 확보할 수 있습니다.

프레임버퍼의 사용법이 당장은 이해하기 어려울 수 있지만, 장면을 다른 프레임버퍼에 렌더링하면 그 결과를 활용하여 장면 내에 대칭 이미지를 만들거나 멋진 후처리 효과를 적용할 수 있습니다. 먼저 프레임버퍼의 작동 원리를 살펴보고, 그 다음에는 이러한 멋진 후처리 효과를 구현해 보겠습니다.

프레임버퍼 만들기

OpenGL의 다른 객체와 마찬가지로 glGenFramebuffers라는 함수를 사용하여 프레임버퍼 객체(약칭 FBO)를 생성할 수 있습니다.

unsigned int fbo;
glGenFramebuffers(1, &fbo);

이러한 객체 생성 및 사용 패턴은 지금까지 수십 번 보아온 것이므로 사용 기능은 이전에 살펴본 다른 객체들과 유사합니다. 먼저 프레임버퍼 객체를 생성하고, 활성 프레임버퍼로 바인딩한 다음, 몇 가지 작업을 수행하고 프레임버퍼 바인딩을 해제합니다. 프레임버퍼를 바인딩하려면 glBindFramebuffer 함수를 사용합니다.

glBindFramebuffer(GL_FRAMEBUFFER, fbo);  

GL_FRAMEBUFFER 타겟에 바인딩하면 이후의 모든 읽기 및 쓰기 프레임버퍼 작업은 현재 바인딩된 프레임버퍼에 영향을 미칩니다. 또한 GL_READ_FRAMEBUFFER 또는 GL_DRAW_FRAMEBUFFER에 바인딩하여 프레임버퍼를 읽기 또는 쓰기 대상으로 지정할 수도 있습니다. GL_READ_FRAMEBUFFER에 바인딩된 프레임버퍼는 glReadPixels와 같은 모든 읽기 작업에 사용되고, GL_DRAW_FRAMEBUFFER에 바인딩된 프레임버퍼는 렌더링, 지우기 및 기타 쓰기 작업의 대상으로 사용됩니다. 하지만 대부분의 경우 이러한 구분이 필요하지 않으며 일반적으로 GL_FRAMEBUFFER를 사용하여 두 대상 모두에 바인딩합니다.

유감스럽게도 프레임버퍼가 아직 완성되지(complete) 않아 사용할 수 없습니다. 프레임버퍼가 완성되려면 다음 요구 사항을 충족해야 합니다.

  • 최소한 하나의 버퍼(컬러 버퍼, 깊이 버퍼 또는 스텐실 버퍼)를 연결해야 합니다.
  • 적어도 하나 이상의 컬러 어태치먼트(Color Attachment)1가 있어야 합니다.
  • 모든 어태치먼트는 반드시 완전한(완성된) 상태여야 합니다(메모리 할당 필수).
  • 각 버퍼에는 동일한 수의 샘플이 포함되어야 합니다.

샘플이 무엇인지 모르시더라도 걱정하지 마세요. 추후 장(안티 에일리어싱 장)에서 설명해 드리겠습니다.

요구 사항을 보면 프레임버퍼에 연결할 수 있는 일종의 어태치먼트(부착물)를 생성하고 이 어태치먼트를 프레임버퍼에 연결해야 한다는 것을 알 수 있습니다. 모든 요구 사항을 충족한 후에는 GL_FRAMEBUFFER를 인수로 사용하는 glCheckFramebufferStatus 함수를 호출하여 프레임버퍼 처리가 성공적으로 완료되었는지 확인할 수 있습니다. 이 함수는 현재 바인딩된 프레임버퍼를 확인하고 사양에 명시된 값 중 하나를 반환합니다. GL_FRAMEBUFFER_COMPLETE가 반환되면 모든 준비가 완료된 것입니다.

if(glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE)
  // 축배를 듭시다!

이후의 모든 렌더링 작업은 현재 바인딩된 프레임버퍼의 어태치먼트에 렌더링됩니다. 이 프레임버퍼는 기본 프레임버퍼가 아니므로 렌더링 명령은 창의 시각적 출력에 영향을 미치지 않습니다. 이러한 이유로 다른 프레임버퍼에 렌더링하는 것을 오프스크린 렌더링이라고 합니다. 모든 렌더링 작업이 메인 창에 다시 시각적 영향을 미치도록 하려면 기본 프레임버퍼를 0에 바인딩하여 활성화해야 합니다.

glBindFramebuffer(GL_FRAMEBUFFER, 0);   

프레임버퍼 관련 작업을 모두 마쳤으면 프레임버퍼 객체를 삭제하는 것을 잊지 마세요.

glDeleteFramebuffers(1, &fbo);  

완전성 검사를 실행하기 전에 프레임버퍼에 하나 이상의 어태치먼트를 연결해야 합니다. 어태치먼트는 프레임버퍼의 버퍼 역할을 하는 메모리 위치로, 이미지와 유사합니다. 어태치먼트를 생성할 때는 텍스처 또는 렌더버퍼 객체 두 가지 옵션이 있습니다.

텍스처 어태치먼트

프레임버퍼에 텍스처를 연결하면 모든 렌더링 명령은 일반적인 컬러/깊이 버퍼 또는 스텐실 버퍼처럼 텍스처에 데이터를 기록합니다. 텍스처를 사용하는 장점은 렌더링 결과가 텍스처 이미지 내부에 저장되므로 셰이더에서 쉽게 사용할 수 있다는 것입니다.

프레임버퍼용 텍스처를 생성하는 방법은 일반 텍스처를 생성하는 방법과 거의 동일합니다.

unsigned int texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);

glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 800, 600, 0, GL_RGB, GL_UNSIGNED_BYTE, NULL);

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);  

여기서 주요 차이점은 (필수 사항은 아니지만) 텍스처의 크기를 화면 크기와 동일하게 설정하고, 텍스처의 데이터 매개변수에 NULL을 전달한다는 것입니다. 이 텍스처의 경우 메모리만 할당하고 실제로 데이터를 채우지는 않습니다. 텍스처 채우기는 프레임 버퍼에 렌더링하는 순간 이루어집니다. 또한 대부분의 경우 래핑 메서드나 밉매핑은 필요하지 않으므로 신경 쓰지 않아도 됩니다.

전체 화면을 더 작거나 큰 크기의 텍스처에 렌더링하려면 (프레임버퍼에 렌더링하기 전에) 새 텍스처 크기를 지정하여 glViewport 함수를 다시 호출해야 합니다. 그렇지 않으면 렌더링 명령이 텍스처의 일부만 채우게 됩니다.2

텍스처를 생성했으니 이제 마지막으로 해야 할 일은 프레임버퍼에 해당 텍스처를 연결하는 것입니다.

glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);  

glFrameBufferTexture2D 함수는 다음과 같은 매개변수를 갖습니다.

  • target: 대상으로 지정할 프레임버퍼 유형(그리기, 읽기 또는 둘 다).
  • attachment: 어태치먼트의 종류입니다. 지금은 컬러 어태치먼트를 첨부하고 있습니다. 끝에 붙은 0은 색상 어태치먼트를 여러 개 첨부할 수 있음을 나타냅니다. 이 부분은 나중에 자세히 다루겠습니다.
  • textarget: 첨부할 텍스처의 유형입니다.
  • texture: 실제로 적용할 텍스처입니다.
  • level: 밉맵 레벨. 여기서는 값을 0으로 설정합니다.

컬러 어태치먼트 외에도 프레임 버퍼 객체에 깊이 텍스처와 스텐실 텍스처를 첨부할 수 있습니다. 깊이 어태치먼트를 첨부하려면 어태치먼트 유형을 GL_DEPTH_ATTACHMENT로 지정합니다. 이때 텍스처의 형식과 내부 형식 유형은 깊이 버퍼의 저장 형식을 반영하도록 GL_DEPTH_COMPONENT로 변경해야 합니다. 스텐실 버퍼를 첨부하려면 두 번째 인수로 GL_STENCIL_ATTACHMENT를 사용하고 텍스처 형식을 GL_STENCIL_INDEX로 지정합니다.

깊이 버퍼와 스텐실 버퍼를 하나의 텍스처로 연결하는 것도 가능합니다. 이 경우 텍스처의 각 32비트 값은 24비트의 깊이 정보와 8비트의 스텐실 정보를 포함합니다. 깊이 버퍼와 스텐실 버퍼를 하나의 텍스처로 연결하려면 GL_DEPTH_STENCIL_ATTACHMENT 타입을 사용하고 텍스처 형식을 깊이 및 스텐실 값이 결합된 형태로 구성합니다. 프레임 버퍼에 깊이 버퍼와 스텐실 버퍼를 하나의 텍스처로 연결하는 예는 아래와 같습니다.

glTexImage2D(
  GL_TEXTURE_2D, 0, GL_DEPTH24_STENCIL8, 800, 600, 0, 
  GL_DEPTH_STENCIL, GL_UNSIGNED_INT_24_8, NULL
);

glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_TEXTURE_2D, texture, 0);

렌더버퍼 어태치먼트

렌더버퍼 객체(Renderbuffer objects)는 텍스처가 도입된 후 프레임버퍼에 데이터를 저장하는 한 유형으로 OpenGL에 추가되었습니다. 텍스처 이미지와 마찬가지로 렌더버퍼 객체는 바이트, 정수, 픽셀 등의 배열로 이루어진 실제 버퍼입니다. 하지만 렌더버퍼 객체는 직접 읽을 수 없습니다. 이러한 특성 덕분에 OpenGL은 메모리 최적화를 통해 프레임버퍼에 오프스크린 렌더링을 수행할 때 텍스처보다 성능 우위를 확보할 수 있습니다.

렌더버퍼 객체는 모든 렌더링 데이터를 텍스처별 형식으로 변환하지 않고 버퍼에 직접 저장하므로 쓰기 가능한 저장 매체로서 속도가 빠릅니다. 렌더버퍼 객체에서 직접 읽을 수는 없지만, 속도가 느린 glReadPixels 함수를 통해 읽을 수 있습니다. 이 함수는 현재 바인딩된 프레임 버퍼에서 지정된 영역의 픽셀 값을 반환하지만, 어태치먼트 자체에서 직접 읽는 것은 아닙니다.

렌더버퍼 객체는 데이터를 네이티브 형식으로 저장하기 때문에 데이터를 쓰거나 다른 버퍼로 복사할 때 속도가 매우 빠릅니다. 따라서 버퍼 전환과 같은 작업도 렌더버퍼 객체를 사용하면 상당히 빠르게 처리됩니다. 매 프레임 끝에서 사용했던 glfwSwapBuffers 함수도 렌더버퍼 객체를 이용해 구현할 수 있습니다. 단순히 하나의 렌더버퍼 이미지에 데이터를 쓰고 마지막에 다른 이미지로 전환하면 됩니다. 렌더버퍼 객체는 이러한 종류의 작업에 매우 적합합니다.

렌더버퍼 객체를 생성하는 코드는 프레임버퍼를 생성하는 코드와 유사합니다.

unsigned int rbo;
glGenRenderbuffers(1, &rbo);

마찬가지로, 모든 후속 렌더버퍼 작업이 현재 렌더버퍼 객체에 영향을 미치도록 렌더버퍼 객체를 바인딩하려고 합니다.

glBindRenderbuffer(GL_RENDERBUFFER, rbo); 

렌더버퍼 객체는 쓰기 전용이기 때문에 깊이 및 스텐실 어태치먼트로 자주 사용됩니다. 대부분의 경우 버퍼에서 값을 읽을 필요는 없지만 깊이 및 스텐실 테스트에는 필요하기 때문입니다. 테스트를 위해 깊이 및 스텐실 값이 필요하지만 이러한 값을 샘플링할 필요는 없으므로 렌더버퍼 객체가 적합합니다. 이러한 버퍼에서 샘플링하지 않는 경우에는 일반적으로 렌더버퍼 객체가 선호됩니다.

깊이 및 스텐실 렌더버퍼 객체를 생성하는 작업은 glRenderbufferStorage 함수를 호출하여 수행됩니다.

glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, 800, 600);

렌더버퍼 객체를 생성하는 것은 텍스처 객체를 생성하는 것과 유사하지만, 이 객체는 텍스처처럼 범용 데이터 버퍼로 사용되는 대신 프레임버퍼에 연결하여 사용하도록 특별히 설계되었다는 점이 다릅니다. 여기서는 내부 형식으로 GL_DEPTH24_STENCIL8을 선택했는데, 이 형식은 깊이 버퍼와 스텐실 버퍼를 각각 24비트와 8비트로 저장합니다.

이제 마지막으로 해야 할 일은 렌더버퍼 객체를 실제로 연결하는 것입니다.

glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, rbo);  

렌더버퍼 객체는 오프스크린 렌더링 프로젝트에서 더 효율적일 수 있지만, 렌더버퍼 객체와 텍스처를 언제 사용해야 하는지 명확히 구분하는 것이 중요합니다. 일반적으로 특정 버퍼에서 데이터를 샘플링할 필요가 전혀 없다면 해당 버퍼에 렌더버퍼 객체를 사용하는 것이 좋습니다. 색상이나 깊이 값과 같이 특정 버퍼에서 데이터를 샘플링해야 하는 경우에는 텍스처를 사용하는 것이 더 적절합니다.

텍스처로 렌더링

프레임버퍼가 어떻게 작동하는지 (대략적으로) 알았으니 이제 실제로 활용해 볼 차례입니다. 우리가 만든 프레임버퍼 객체에 연결된 컬러 텍스처에 장면을 렌더링한 다음, 이 텍스처를 화면 전체를 덮는 간단한 사각형 영역 위에 그려 보겠습니다. 시각적 출력은 프레임버퍼를 사용하지 않았을 때와 완전히 동일하지만, 이번에는 모든 내용이 하나의 사각형 영역 위에 출력됩니다. 그렇다면 이것이 왜 유용할까요? 다음 섹션에서 그 이유를 살펴보겠습니다.

가장 먼저 해야 할 일은 실제 프레임버퍼 객체를 생성하고 바인딩하는 것입니다. 이 과정은 비교적 간단합니다.

unsigned int framebuffer;
glGenFramebuffers(1, &framebuffer);
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);   

다음으로 프레임버퍼에 컬러 어태치먼트로 사용할 텍스처 이미지를 생성합니다. 텍스처의 크기를 창의 너비와 높이와 같게 설정하고 데이터는 초기화하지 않은 상태로 유지합니다.

// 텍스처 생성
unsigned int textureColorbuffer;
glGenTextures(1, &textureColorbuffer);
glBindTexture(GL_TEXTURE_2D, textureColorbuffer);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 800, 600, 0, GL_RGB, GL_UNSIGNED_BYTE, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR );
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glBindTexture(GL_TEXTURE_2D, 0);

// 현재 바인딩된 프레임버퍼 객체에 연결
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, textureColorbuffer, 0);

또한 OpenGL이 깊이 테스트(및 선택적으로 스텐실 테스트)를 수행할 수 있도록 프레임 버퍼에 깊이(및 스텐실) 어태치먼트를 추가해야 합니다. 다른 버퍼가 아닌 컬러 버퍼만 샘플링하므로 이 목적을 위해 렌더버퍼 객체를 생성할 수 있습니다.

렌더버퍼 객체를 생성하는 것은 그리 어렵지 않습니다. 다만, 깊이 및 스텐실를 같이 첨부하는 렌더버퍼 객체로 생성한다는 점을 기억해야 합니다. 내부 형식을 GL_DEPTH24_STENCIL8로 설정하는데, 이는 우리의 목적에 충분한 정밀도입니다.

unsigned int rbo;
glGenRenderbuffers(1, &rbo);
glBindRenderbuffer(GL_RENDERBUFFER, rbo); 
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, 800, 600);  
glBindRenderbuffer(GL_RENDERBUFFER, 0);

렌더버퍼 객체에 충분한 메모리를 할당했으면 렌더버퍼 바인딩을 해제할 수 있습니다.

그런 다음, 프레임버퍼를 완성하기 전 마지막 단계로 렌더버퍼 객체를 프레임버퍼의 깊이 및 스텐실 첨부점에 연결합니다.

glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, rbo);

다음으로 프레임버퍼가 완성되었는지 확인하고, 완성되지 않았다면 오류 메시지를 출력합니다.

if(glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
    std::cout << "ERROR::FRAMEBUFFER:: Framebuffer is not complete!" << std::endl;
glBindFramebuffer(GL_FRAMEBUFFER, 0);  

프레임버퍼 바인딩을 해제하여 실수로 잘못된 프레임버퍼에 렌더링하는 일이 없도록 해야 합니다.

프레임버퍼 생성이 완료되었으므로, 기본 프레임버퍼 대신 해당 프레임버퍼의 버퍼에 렌더링하려면 프레임버퍼 객체를 바인딩하기만 하면 됩니다. 이후의 모든 렌더링 명령은 현재 바인딩된 프레임버퍼에 영향을 미칩니다. 깊이 및 스텐실 연산 또한 현재 바인딩된 프레임버퍼에 깊이 및 스텐실 첨부 파일이 있는 경우 해당 파일에서 데이터를 읽어옵니다. 예를 들어 깊이 버퍼를 생략하면 모든 깊이 테스트 연산이 더 이상 작동하지 않습니다.

따라서 장면 전체를 하나의 텍스처로 표현하려면 다음과 같은 단계를 거쳐야 합니다.

  • 새 프레임버퍼를 활성 프레임버퍼로 지정하여 평소처럼 장면을 렌더링합니다.
  • 기본 프레임버퍼에 바인딩합니다.
  • 새 프레임버퍼의 컬러 버퍼를 텍스처로 사용하여 화면 전체에 걸쳐 있는 사각형을 그립니다.

깊이 테스트 챕터에서 사용했던 것과 동일한 장면을 렌더링하되, 이번에는 기존 방식의 컨테이너 텍스처를 사용하겠습니다.

사각형을 렌더링하기 위해 간단한 셰이더 세트를 새로 만들겠습니다. 정점 좌표를 정규화된 장치 좌표로 제공할 것이므로 복잡한 행렬 변환은 포함하지 않고, 정점 셰이더의 출력으로 직접 전달할 것입니다. 정점 셰이더는 다음과 같습니다.

#version 330 core
layout (location = 0) in vec2 aPos;
layout (location = 1) in vec2 aTexCoords;

out vec2 TexCoords;

void main()
{
    gl_Position = vec4(aPos.x, aPos.y, 0.0, 1.0); 
    TexCoords = aTexCoords;
}  

특별히 복잡한 건 없습니다. 프래그먼트 셰이더는 훨씬 더 간단한데, 우리가 해야 할 일은 텍스처에서 샘플링하는 것뿐이기 때문입니다.

#version 330 core
out vec4 FragColor;

in vec2 TexCoords;

uniform sampler2D screenTexture;

void main()
{ 
    FragColor = texture(screenTexture, TexCoords);
}

그다음에는 화면 사각형에 대한 VAO를 생성하고 구성하는 것은 사용자에게 달려 있습니다. 프레임버퍼 절차의 단일 렌더링 반복은 다음과 같은 구조를 갖습니다.

// 첫 번째 패스
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
glClearColor(0.1f, 0.1f, 0.1f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // 지금은 스텐실 버퍼를 사용하지 않습니다.
glEnable(GL_DEPTH_TEST);
DrawScene();    

// 두 번째 패스
glBindFramebuffer(GL_FRAMEBUFFER, 0); // 기본값으로 세팅
glClearColor(1.0f, 1.0f, 1.0f, 1.0f); 
glClear(GL_COLOR_BUFFER_BIT);

screenShader.use();  
glBindVertexArray(quadVAO);
glDisable(GL_DEPTH_TEST);
glBindTexture(GL_TEXTURE_2D, textureColorbuffer);
glDrawArrays(GL_TRIANGLES, 0, 6);  

몇 가지 유의할 사항이 있습니다. 첫째, 각 프레임 버퍼는 고유한 버퍼 세트를 가지고 있으므로 glClear 함수를 호출하여 각 버퍼를 적절한 비트로 설정하여 지워야 합니다. 둘째, 사각형을 그릴 때는 깊이 테스트를 비활성화합니다. 이는 사각형이 항상 다른 모든 요소보다 앞에 렌더링되도록 하기 위함입니다. 하지만 일반 장면을 그릴 때는 깊이 테스트를 다시 활성화해야 합니다.

이 과정에서 오류가 발생할 수 있는 단계가 꽤 많으므로, 출력이 나오지 않으면 가능한 한 디버깅을 시도하고 해당 장의 관련 부분을 다시 읽어보세요. 모든 과정이 성공적으로 완료되면 다음과 같은 시각적 결과가 나타납니다.

왼쪽은 시각적 출력 결과를 보여줍니다. 깊이 테스트 챕터에서 봤던 것과 완전히 동일하지만, 이번에는 단순한 사각형에 렌더링된 것입니다. 장면을 와이어프레임으로 렌더링하면 기본 프레임버퍼에 사각형 하나만 그려졌다는 것을 알 수 있습니다.

여기에서 애플리케이션의 소스 코드를 찾을 수 있습니다.

그렇다면 이것이 대체 무슨 용도로 쓰이는 걸까요? 이제 완전히 렌더링된 장면의 각 픽셀에 단일 텍스처 이미지로 자유롭게 접근할 수 있게 되었기 때문에, 프래그먼트 셰이더에서 흥미로운 효과들을 만들어낼 수 있게 된 것입니다.

후처리

이제 전체 장면이 하나의 텍스처로 렌더링되었으므로 장면 텍스처를 조작하여 멋진 후처리(post-processing) 효과를 만들 수 있습니다. 이 섹션에서는 몇 가지 인기 있는 후처리 효과와 창의력을 발휘하여 자신만의 효과를 만드는 방법을 보여드리겠습니다.

가장 간단한 후처리 효과 중 하나부터 시작해 보겠습니다.

반전

렌더링 출력의 각 색상에 접근할 수 있으므로 프래그먼트 셰이더에서 이러한 색상의 반전값을 반환하는 것은 그리 어렵지 않습니다. 화면 텍스처의 색상을 가져와 1.0에서 빼서 반전시키면 됩니다.

void main()
{
    FragColor = vec4(vec3(1.0 - texture(screenTexture, TexCoords)), 1.0);
}  

반전 효과는 비교적 간단한 후처리 효과이지만, 이미 독특한 결과물을 만들어냅니다.

이제 프래그먼트 셰이더에 단 한 줄의 코드만 추가하면 장면 전체의 색상이 반전됩니다. 정말 멋지지 않나요?

흑백

또 다른 흥미로운 효과는 장면에서 흰색, 회색, 검은색을 제외한 모든 색상을 제거하는 것입니다. 즉, 이미지 전체를 흑백으로 만드는 것이죠. 이를 쉽게 하는 방법은 모든 색상 구성 요소를 가져와서 평균을 내는 것입니다.

void main()
{
    FragColor = texture(screenTexture, TexCoords);
    float average = (FragColor.r + FragColor.g + FragColor.b) / 3.0;
    FragColor = vec4(average, average, average, 1.0);
}   

이것만으로도 꽤 괜찮은 결과가 나오지만, 사람의 눈은 녹색에 가장 민감하고 파란색에는 가장 둔감한 경향이 있습니다. 따라서 물리적으로 가장 정확한 결과를 얻으려면 가중치 채널을 사용해야 합니다.

void main()
{
    FragColor = texture(screenTexture, TexCoords);
    float average = 0.2126 * FragColor.r + 0.7152 * FragColor.g + 0.0722 * FragColor.b;
    FragColor = vec4(average, average, average, 1.0);
} 

아마 당장은 차이를 알아차리지 못할 수도 있지만, 복잡한 장면에서는 이러한 가중치 회색조 효과가 더 사실적으로 보이는 경향이 있습니다.

커널 효과

단일 텍스처 이미지에 후처리 작업을 수행하는 또 다른 장점은 해당 프래그먼트에 국한되지 않은 텍스처의 다른 부분에서 색상 값을 샘플링할 수 있다는 것입니다. 예를 들어 현재 텍스처 좌표 주변의 작은 영역을 선택하고 현재 텍스처 값 주변의 여러 텍스처 값을 샘플링할 수 있습니다. 그런 다음 이러한 값들을 창의적인 방식으로 조합하여 흥미로운 효과를 만들어낼 수 있습니다.

커널(또는 컨볼루션 행렬)은 현재 픽셀을 중심으로 하는 작은 행렬 형태의 값 배열로, 주변 픽셀 값에 커널 값을 곱한 후 모두 더하여 하나의 값을 생성합니다. 현재 픽셀 주변 방향의 텍스처 좌표에 작은 오프셋을 더하고 커널을 기반으로 결과를 결합하는 것입니다. 커널의 예는 아래와 같습니다.

\[ \begin{bmatrix}2 & 2 & 2 \\ 2 & -15 & 2 \\ 2 & 2 & 2 \end{bmatrix} \]

이 커널은 주변 8개 픽셀 값을 입력받아 각각 2를 곱하고, 현재 픽셀 값에는 -15를 곱합니다. 예시 커널은 주변 픽셀 값에 커널 내에서 결정된 여러 가중치를 곱하고, 현재 픽셀 값에 큰 음수 가중치를 곱하여 결과의 ​​균형을 맞춥니다.

인터넷에서 찾을 수 있는 대부분의 커널은 모든 가중치를 더하면 합계가 1이 됩니다. 만약 합계가 1이 되지 않는다면, 결과적으로 생성되는 텍스처 색상이 원래 텍스처 값보다 더 밝거나 어둡다는 의미입니다.

커널은 사용과 실험이 매우 간편하고 온라인에서 많은 예제를 찾을 수 있기 때문에 후처리 작업에 매우 유용한 도구입니다. 다만, 커널을 실제로 지원하려면 프래그먼트 셰이더를 약간 수정해야 합니다. 여기서는 사용할 각 커널이 3x3 커널이라고 가정합니다(대부분의 커널이 3x3 크기입니다).

const float offset = 1.0 / 300.0;  

void main()
{
    vec2 offsets[9] = vec2[](
        vec2(-offset,  offset), // top-left
        vec2( 0.0f,    offset), // top-center
        vec2( offset,  offset), // top-right
        vec2(-offset,  0.0f),   // center-left
        vec2( 0.0f,    0.0f),   // center-center
        vec2( offset,  0.0f),   // center-right
        vec2(-offset, -offset), // bottom-left
        vec2( 0.0f,   -offset), // bottom-center
        vec2( offset, -offset)  // bottom-right    
    );

    float kernel[9] = float[](
        -1, -1, -1,
        -1,  9, -1,
        -1, -1, -1
    );

    vec3 sampleTex[9];
    for(int i = 0; i < 9; i++)
    {
        sampleTex[i] = vec3(texture(screenTexture, TexCoords.st + offsets[i]));
    }
    vec3 col = vec3(0.0);
    for(int i = 0; i < 9; i++)
        col += sampleTex[i] * kernel[i];

    FragColor = vec4(col, 1.0);
}  

프래그먼트 셰이더에서는 먼저 주변 텍스처 좌표 각각에 대한 9개의 vec2 오프셋 배열을 생성합니다. 오프셋은 상수 값이며 원하는 대로 사용자 지정할 수 있습니다. 다음으로 커널을 정의하는데, 이 경우에는 주변 픽셀을 흥미로운 방식으로 샘플링하여 각 색상 값을 선명하게 만드는 샤프닝 커널을 사용합니다. 마지막으로 샘플링할 때 각 오프셋을 현재 텍스처 좌표에 더하고, 이러한 텍스처 값들을 가중치가 적용된 커널 값들과 곱합니다.

우리가 사용한 샤프닝 커널의 결과물은 다음과 같습니다.

이는 플레이어가 약물에 취한 상태로 모험을 떠나는 흥미로운 상황의 기반이 될 수 있습니다.

흐림 효과

흐림 효과(blur, 블러)를 생성하는 커널은 다음과 같이 정의됩니다.

\[ \begin{bmatrix} 1 & 2 & 1 \\ 2 & 4 & 2 \\ 1 & 2 & 1 \end{bmatrix} / 16 \]

모든 값의 합이 16이므로, 샘플링된 색상을 직접 합산하여 반환하면 매우 밝은 색상이 나타나게 됩니다. 따라서 커널의 각 값을 16으로 나누어야 합니다. 그 결과 커널 배열은 다음과 같습니다.

프래그먼트 셰이더의 커널 배열만 변경하면 후처리 효과를 완전히 바꿀 수 있습니다. 이제 결과는 다음과 같습니다.

이러한 흐림 효과는 흥미로운 가능성을 열어줍니다. 예를 들어, 시간에 따라 흐림 정도를 변화시켜 술에 취한 사람의 모습을 표현하거나, 주인공이 안경을 쓰지 않았을 때 흐림 정도를 높일 수 있습니다. 또한 흐림 효과는 색상 값을 부드럽게 하는 데 유용한 도구이며, 이는 이후 장에서 자세히 다룰 것입니다.

보시다시피, 이렇게 간단한 커널 구현만 있으면 멋진 후처리 효과를 아주 쉽게 만들 수 있습니다. 마지막으로 인기 있는 효과 하나를 더 보여드리면서 이 장을 마무리하겠습니다.

엣지 검출

아래에서 선명도 향상 커널과 유사한 엣지 검출(edge-detection) 커널을 찾을 수 있습니다.

\[ \begin{bmatrix} 1 & 1 & 1 \\ 1 & -8 & 1 \\ 1 & 1 & 1 \end{bmatrix} \]

이 커널은 모든 가장자리를 강조하고 나머지는 어둡게 처리하는데, 이미지에서 가장자리만 필요한 경우에 매우 유용합니다.

이러한 커널이 포토샵과 같은 이미지 편집 도구/필터로 사용되는 것은 그리 놀라운 일이 아닐 것입니다. 그래픽 카드는 극도로 병렬적인 처리 능력을 통해 이미지 조각을 처리할 수 있기 때문에, 픽셀 단위로 이미지를 실시간으로 비교적 쉽게 조작할 수 있습니다. 따라서 이미지 편집 도구는 이미지 처리에 그래픽 카드를 사용하는 경향이 있습니다.

연습 문제

  • 프레임버퍼를 사용해서 백미러를 만들 수 있을까요? 이를 위해서는 장면을 두 번 그려야 합니다. 하나는 카메라가 180도 회전된 모습이고, 다른 하나는 정상적인 모습입니다. 화면 상단에 작은 사각형 영역을 만들어서 그 위에 백미러 텍스처를 적용해 보세요. 예를 들어 이런 식으로요. (해결 방법)
  • 커널 값을 다양하게 조절하여 자신만의 흥미로운 후처리 효과를 만들어 보세요. 인터넷에서 다른 흥미로운 커널을 검색해 보는 것도 좋습니다.

  1. 어태치먼트는 프레임버퍼에 연결되어 GPU가 렌더링 과정에서 생성한 색상, 깊이, 또는 스텐실 데이터를 실제로 저장하는 버퍼(이미지)이며, 프레임버퍼는 이러한 어태치먼트들을 담는 컨테이너 역할을 합니다. ↩

  2. 프레임버퍼에 바인딩 된 상태로 그리기 직전에 glViewport 함수를 호출해야 합니다. 또한 화면에 그리기 전에는 다시 glViewport 함수를 호출하여 원래 화면 크기로 돌아와야 합니다. ↩

Comments