후처리

후처리(post processing)란 보통 2D 이미지에 어떤 효과나 필터를 넣는 것을 의미합니다. Three.js는 다양한 mesh로 이루어진 장면을 2D 이미지로 렌더링하죠. 일반적으로 이 이미지는 바로 캔버스를 통해 브라우저 화면에 렌더링됩니다. 하지만 대신 이 이미지를 렌더 타겟에 렌더링하고 캔버스에 보내기 전 임의의 후처리 효과를 줄 수 있습니다.

인스타그램 필터, 포토샵 필터 등이 후처리의 좋은 예이죠.

Three.js에는 후처리를 순차적으로 처리해주는 모범 클래스가 있습니다. 일단 EffectComposer의 인스턴스를 만들고 여기에 Pass 객체(효과, 필터)들을 추가합니다. 그리고 EffectComposer.render 메서드를 호출하면 현재 장면을 렌더 타겟에 렌더링한 뒤 각 pass*를 순서대로 적용합니다.

※ 편의상 Pass 인스턴스를 pass로 번역합니다.

이 pass는 비넷(vignette), 흐림(blur), 블룸(bloom), 필름 그레인(film grain) 효과 또는 hue, 채도(saturation), 대비(contrast) 조정 등의 후처리 효과로, 이 효과를 모두 적용한 결과물을 최종적으로 캔버스에 렌더링합니다.

여기서 어느 정도 EffectComposer의 원리를 이해할 필요가 있습니다. EffectComposer는 두 개의 렌더 타겟을 사용합니다. 편의상 이 둘을 rtA, rtB라고 부르도록 하죠.

EffectComposer.addPass를 각 pass를 적용할 순서대로 호출하고 EffectComposer.render를 호출하면 pass*는 아래 그림과 같은 순서로 적용됩니다.

먼저 RenderPass에 넘긴 장면을 rtA에 렌더링합니다. 그리고 rtA를 다음 pass에 넘겨주면 해당 pass는 rtA에 pass를 적용한 결과를 rtB에 렌더링합니다. 그런 다음 rtB를 다음 pass로 넘겨 적용한 결과를 rtA에, rtA에 pass를 적용한 결과를 다시 rtB에, 이런 식으로 모든 pass가 끝날 때까지 계속 반복합니다.

Pass에는 공통적으로 4가지 옵션이 있습니다.

enabled

이 pass를 사용할지의 여부입니다.

needsSwap

이 pass를 적용한 후 rtArtB를 바꿀지의 여부입니다.

clear

이 pass를 적용하기 전에 화면을 초기화할지의 여부입니다.

renderToScreen

지정한 렌더 타겟이 아닌 캔버스에 렌더링할지의 여부입니다. 보통 EffectComposer에 추가하는 마지막 pass에 이 옵션을 true로 설정합니다.

간단한 예제를 만들어봅시다. 반응형 디자인에 관한 글에서 썼던 예제를 가져오겠습니다.

추가로 먼저 EffectComposer 인스턴스를 생성합니다.

const composer = new EffectComposer(renderer);

다음으로 RenderPass를 첫 pass로 추가합니다. 이 pass는 넘겨 받은 장면을 첫 렌더 타겟에 렌더링할 겁니다.

composer.addPass(new RenderPass(scene, camera));

다음으로 BloomPass를 추가합니다. BloomPass는 장면을 원래의 장면보다 작게 렌더링해 흐림(blur) 효과를 줍니다. 그리고 효과가 적용된 장면을 원래 장면에 덮어 씌우는 식으로 블룸 효과를 구현합니다.

const bloomPass = new BloomPass(
    1,    // 강도
    25,   // 커널(kernel) 크기
    4,    // 시그마 ?
    256,  // 렌더 타겟의 해상도를 낮춤
);
composer.addPass(bloomPass);

마지막으로 원본 장면에 노이즈와 스캔라인(scanline)을 추가하는 FilmPass를 추가합니다.

const filmPass = new FilmPass(
    0.35,   // 노이즈 강도
    0.025,  // 스캔라인 강도
    648,    // 스캔라인 개수
    false,  // 흑백
);
filmPass.renderToScreen = true;
composer.addPass(filmPass);

filmPass가 마지막 pass이기에 캔버스에 결과를 바로 렌더링하도록 renderToScreen 옵션을 true로 설정했습니다. 이 옵션을 설정하지 않으면 캔버스가 아닌 다음 렌더 타겟에 장면을 렌더링할 거예요.

또 이 클래스들을 사용하기 위해 여러 스크립트를 불러와야 합니다.

import { EffectComposer } from '/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from '/examples/jsm/postprocessing/RenderPass.js';
import { BloomPass } from '/examples/jsm/postprocessing/BloomPass.js';
import { FilmPass } from '/examples/jsm/postprocessing/FilmPass.js';

대부분의 후처리에는 EffectComposer.jsRenderPass.js가 필수입니다.

이제 WebGLRenderer.render 대신 EffectComposer.render를 사용하고 EffectComposer가 결과물을 캔버스의 크기에 맞추도록 해야 합니다.

-function render(now) {
-  time *= 0.001;
+let then = 0;
+function render(now) {
+  now *= 0.001;  // 초 단위로 변환
+  const deltaTime = now - then;
+  then = now;

  if (resizeRendererToDisplaySize(renderer)) {
    const canvas = renderer.domElement;
    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    camera.updateProjectionMatrix();
+    composer.setSize(canvas.width, canvas.height);
  }

  cubes.forEach((cube, ndx) => {
    const speed = 1 + ndx * .1;
-    const rot = time * speed;
+    const rot = now * speed;
    cube.rotation.x = rot;
    cube.rotation.y = rot;
  });

-  renderer.render(scene, camera);
+  composer.render(deltaTime);

  requestAnimationFrame(render);
}

EffectComposer.render 메서드는 인자로 마지막 프레임을 렌더링한 이후의 시간값인 deltaTime을 인자로 받습니다. pass에 애니메이션이 필요할 경우를 대비해 이 값을 넘겨주기 위해서이죠. 예제의 경우에는 FilmPass에 애니메이션이 있습니다.

런타임에 효과의 속성을 변경할 때는 보통 uniform의 value 값을 바꿉니다. GUI를 추가해 이 속성을 조정할 수 있게 만들어보죠. 어떤 속성을 어떻게 조작할 수 있는지는 해당 효과의 소스 코드를 열어봐야 알 수 있습니다.

BloomPass.js에서 아래 코드를 찾았습니다.

this.copyUniforms[ "opacity" ].value = strength;

아래처럼 하면 강도를 런타임에 바꿀 수 있겠네요.

bloomPass.copyUniforms.opacity.value = someValue;

마찬가지로 FilmPass.js에서 아래 코드를 찾았습니다.

if ( grayscale !== undefined )    this.uniforms.grayscale.value = grayscale;
if ( noiseIntensity !== undefined ) this.uniforms.nIntensity.value = noiseIntensity;
if ( scanlinesIntensity !== undefined ) this.uniforms.sIntensity.value = scanlinesIntensity;
if ( scanlinesCount !== undefined ) this.uniforms.sCount.value = scanlinesCount;

이제 어떻게 값을 지정해야 하는지 알았으니 이 값을 조작하는 GUI를 만들어봅시다.

import { GUI } from '/examples/jsm/libs/lil-gui.module.min.js';

일단 모듈을 로드합니다.

const gui = new GUI();
{
  const folder = gui.addFolder('BloomPass');
  folder.add(bloomPass.copyUniforms.opacity, 'value', 0, 2).name('strength');
  folder.open();
}
{
  const folder = gui.addFolder('FilmPass');
  folder.add(filmPass.uniforms.grayscale, 'value').name('grayscale');
  folder.add(filmPass.uniforms.nIntensity, 'value', 0, 1).name('noise intensity');
  folder.add(filmPass.uniforms.sIntensity, 'value', 0, 1).name('scanline intensity');
  folder.add(filmPass.uniforms.sCount, 'value', 0, 1000).name('scanline count');
  folder.open();
}

이제 각 설정을 조작할 수 있습니다.

여기까지 잘 따라왔다면 이제 효과를 직접 만들어볼 수 있습니다.

후처리 효과는 쉐이더를 사용합니다. 쉐이더는 GLSL (Graphics Library Shading Language)이라는 언어를 사용하죠. 언어가 방대해 이 글에서 전부 다루기는 어렵습니다. 기초부터 알아보고 싶다면 이 글쉐이더란 무엇인가(The Book of Shaders)를 읽어보기 바랍니다.

직접 예제를 만들어보는 게 도움이 될 테니 간단한 GLSL 후처리 쉐이더를 만들어봅시다. 이미지에 특정 색을 혼합하는 쉐이더를 만들 겁니다.

Three.js에는 후처리를 도와주는 ShaderPass 헬퍼 클래스가 있습니다. 인자로 vertex 쉐이더, fragment 쉐이더, 기본값으로 이루어진 객체를 받죠. 이 클래스는 이전 pass의 결과물에서 어떤 텍스처를 읽을지, 그리고 EffectComposer의 렌더 타겟과 캔버스 중 어디에 렌더링할지를 결정할 겁니다.

아래는 이전 pass의 결과물에 특정 색을 혼합하는 간단한 후처리 쉐이더입니다.

const colorShader = {
  uniforms: {
    tDiffuse: { value: null },
    color:    { value: new THREE.Color(0x88CCFF) },
  },
  vertexShader: `
    varying vec2 vUv;
    void main() {
      vUv = uv;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1);
    }
  `,
  fragmentShader: `
    varying vec2 vUv;
    uniform sampler2D tDiffuse;
    uniform vec3 color;
    void main() {
      vec4 previousPassColor = texture2D(tDiffuse, vUv);
      gl_FragColor = vec4(
          previousPassColor.rgb * color,
          previousPassColor.a);
    }
  `,
};

위 코드에서 tDiffuse는 이전 pass의 결과물을 받아오기 위한 것으로 거의 모든 경우에 필수입니다. 그리고 그 바로 밑에 color 속성을 Three.js의 Color로 선언했습니다.

다음으로 vertex 쉐이더를 작성해야 합니다. 위 코드에서 작성한 vertex 쉐이더는 후처리에서 거의 표준처럼 사용하는 코드로, 대부분의 경우 바꿀 필요가 없습니다. 뭔가 많이 설정한 경우(아까 언급한 링크 참조)가 아니라면 uv, projectionMatrix, modelViewMatrix, position 변수는 Three.js가 알아서 넣어줍니다.

마지막으로 fragment 쉐이더를 생성합니다. 아래 코드로 이전 pass에서 넘겨준 결과물의 픽셀 색상값을 가져올 수 있습니다.

vec4 previousPassColor = texture2D(tDiffuse, vUv);

여기에 지정한 색상을 곱해 gl_FragColor에 결과를 저장합니다.

gl_FragColor = vec4(
    previousPassColor.rgb * color,
    previousPassColor.a);

추가로 간단한 GUI를 만들어 rgb의 각 색상값을 조정할 수 있도록 합니다.

const gui = new GUI();
gui.add(colorPass.uniforms.color.value, 'r', 0, 4).name('red');
gui.add(colorPass.uniforms.color.value, 'g', 0, 4).name('green');
gui.add(colorPass.uniforms.color.value, 'b', 0, 4).name('blue');

색을 혼합하는 간단한 후처리 쉐이더를 완성했습니다.

언급했듯 이 글에서 GLSL의 작성법과 사용자 지정 쉐이더를 만드는 법을 모두 다루기는 무리입니다. WebGL이 어떻게 동작하는지 알고 싶다면 이 시리즈를 참고하세요. Three.js의 후처리 쉐이더 소스 코드를 분석하는 것도 좋은 방법입니다. 상대적으로 복잡한 쉐이더도 있지만 작은 것부터 차근차근 살펴본다면 언젠가 전체를 이해할 수 있을 거예요.

아쉽게도 Three.js의 후처리 효과 대부분은 공식 문서가 없어 예제를 참고하거나 후처리 효과의 소스 코드를 직접 분석해야 합니다. 부디 이 글과 이 시리즈의 렌더 타겟에 관한 글이 좋은 출발점을 마련해주었으면 좋겠네요.