후처리 (Post-processing)

0

Three.js의 후처리(post-processing)는 빛번짐(bloom), 피사계 심도(depth of field), 글리치(glitch) 같은 다양한 효과를 적용하여 렌더링된 3D 장면의 시각적 품질을 향상시키는 기법이다. 이 효과들은 주 렌더링 과정 이후에 추가되어, 개발자가 장면의 최종 외형을 제어할 수 있게 한다. 이 글은 몇 가지 후처리 pass를 설명하고 Three.js에서 후처리를 적용하는 방법을 설명한다.

후처리는 렌더러가 이미지를 생성한 후 그 렌더링된 이미지에 시각 효과를 적용하는 과정이다. 이를 통해 개발자는 영화적인 효과를 추가하거나 또는 사실감을 높이는 등 표준 3D 렌더링 파이프라인에서는 달성할 수 없는 방식으로 장면을 표현할 수 있다.

Effect Composer

EffectComposer는 Three.js에서 후처리를 관리하는 데 사용되는 핵심 클래스다. EffectComposer를 도입하면, 장면은 WebGLRenderer 대신 EffectComposer의 연속된 pass들을 통해 렌더링된다. 각 pass는 이전 pass의 출력을 순차적으로 처리한다.

import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
const composer = new EffectComposer(renderer);

Render Pass

render pass는 후처리 파이프라인의 첫 단계다. RenderPassWebGLRenderer와 동일한 역할을 하며, 3D 객체를 2D 장면으로 렌더링한다. RenderPass가 없으면 EffectComposer는 후처리 효과를 적용할 기본 이미지가 없다. RenderPass 생성자는 scenecamera 객체를 받는다.

import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);

Bloom Pass

Bloom Pass는 장면의 밝은 영역 주위에 빛나는 효과를 만든다. 이 효과는 실제 카메라의 동작에서 영감을 받았는데, 장면의 매우 밝은 부분이 번지거나 빛나 보이는 현상이다. Three.js에서 Bloom Pass를 사용하는 예제는 다음과 같다.

import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
const bloomPass = new UnrealBloomPass(
  new THREE.Vector2(window.innerWidth, window.innerHeight), // Resolution
  1.5, // Strength of the glow
  0.4, // Radius of the glow
  0.85 // Threshold for brightness
);
composer.addPass(bloomPass);

여기서 resolution은 블룸 효과의 품질을 결정한다. 해상도가 높을수록 더 선명한 결과를 주지만 성능이 저하될 수 있다. strength는 블룸 효과의 강도를 제어한다. radius는 블룸이 퍼지는 영역을 지정하고, threshold는 밝기 임계값을 설정한다. 이 값보다 밝은 픽셀만 블룸한다.

Shader Pass

위의 pass들은 Three.js에 정의된 사전 내장 클래스다. 반면 ShaderPass는 커스텀 셰이더를 사용해 효과를 구현한다. 이는 커스텀 후처리 효과를 만드는 데 높은 유연성을 제공하며, GLSL 코드로 구현된다.

ShaderPass는 파이프라인에서 이전 pass가 생성한 이미지 데이터에 커스텀 셰이더를 적용한다. 두 가지 주요 구성 요소를 사용한다:

  • 정점 셰이더: geometry가 어떻게 처리되는지 정의한다(후처리에서는 보통 이 작업이 거의 없다).
  • 프래그먼트 셰이더: 픽셀이 어떻게 셰이딩되거나 조작되는지 정의한다. 후처리 효과에서 대부분의 작업이 여기서 일어난다.
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
const shaderPass = new ShaderPass(
  new THREE.ShaderMaterial({
    uniforms: {},
    vertexShader: ``, // vertex shader GLSL code
    fragmentShader: ``, // fragment shader GLSL code
  })
);
composer.addPass(shaderPass);

통과(pass-through) ShaderPass의 정점 셰이더와 프래그먼트 셰이더는 다음과 같이 정의할 수 있다.

// vertex shader
varying vec2 vUv;
void main() {
  vUv = uv;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
// fragment shader
uniform sampler2D tDiffuse;
varying vec2 vUv;
void main() {
  vec4 color = texture2D(tDiffuse, vUv);
  gl_FragColor = color;
}

위에서 tDiffuse는 Shader Pass에서 사용되는 표준 uniform으로, 이전 pass에서 렌더링된 장면의 텍스처를 나타낸다. 또한 uniforms를 통해 추가 데이터(예: 시간, 커스텀 파라미터)를 셰이더로 전달할 수 있다.

Antialiasing Pass

후처리용 안티앨리어싱(antialiasing) 알고리즘은 셰이더 프로그램으로 구현된다. 따라서 ShaderPass를 이용해 안티앨리어싱을 적용할 수 있다. Three.js 후처리에서는 SSAA(super sampling antialiasing), FXAA(fast approximation antialiasing), SMAA(enhanced subpixel morphological antialiasing) 등이 내장 코드로 제공된다. EffectComposer로 후처리를 사용하면 WebGL의 내장 안티앨리어싱 패스를 거치지 않으므로, 기본적으로 안티앨리어싱이 적용되지 않는다. 따라서 최종 렌더에서 후처리 파이프라인 끝에 안티앨리어싱 패스를 직접 추가해야 한다.

import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
import { FXAAShader } from 'three/addons/shaders/FXAAShader.js';
const fxaaPass = new ShaderPass( FXAAShader );
const pixelRatio = renderer.getPixelRatio();
fxaaPass.material.uniforms[ 'resolution' ].value.x = 1 / ( window.innerWidth * pixelRatio ); // set resolution of antialiasing
fxaaPass.material.uniforms[ 'resolution' ].value.y = 1 / ( window.innerHeight * pixelRatio );

기타 Pass

Three.js에는 사전 내장된 pass가 많이 있다. 후처리의 인터랙티브 데모는 여기에서 둘러볼 수 있다. 아래는 몇 가지 후처리 pass를 간략히 설명한다.

  • BokehPass: 카메라의 초점을 흉내 내어 초점면 밖의 객체를 흐리게 한다.
  • RenderPixelatedPass: 마인크래프트처럼 장면에 픽셀화 효과를 더한다.
  • GlitchPass: 무작위 시각에 글리치 효과를 만든다.

렌더링

다음은 Three.js 장면에 후처리를 통합하는 간단한 예제다. 평면 위에 떠 있는 큐브를 만든 다음, 안티에일리어싱과 블룸 후처리를 적용했다.

블룸 효과 없음 블룸 효과 적용
예제 코드
import * as THREE from 'three'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
import { FXAAShader } from 'three/addons/shaders/FXAAShader.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';

const canvas = document.createElement("canvas");
document.body.appendChild(canvas);

const renderer = new THREE.WebGLRenderer({canvas: canvas, alpha: true});
renderer.shadowMap.enabled = true;
renderer.setPixelRatio(window.devicePixelRatio);

const scene = new THREE.Scene();

// setup camera
const width = canvas.width;
const height = canvas.height;
const camera = new THREE.PerspectiveCamera(50, width/height, 0.1, 2 * 400);
camera.position.x = 0;
camera.position.y = 6;
camera.position.z = 8;

// setup cube
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({
  color: 'red',
})

const cube = new THREE.Mesh(geometry, material);
cube.castShadow = true;
cube.receiveShadow = true;
scene.add(cube);

// setup plane
const geo_plane = new THREE.PlaneGeometry(50,50,1,1);
geo_plane.rotateX(-Math.PI/2);
geo_plane.translate(0,-1,0);
const mat_plane = new THREE.MeshStandardMaterial({
  color: 'white',
})

const plane = new THREE.Mesh(geo_plane, mat_plane);
plane.castShadow = true;
plane.receiveShadow = true;
scene.add(plane);

// setup light
const light = new THREE.PointLight( 0xffffff, 20, 10, 2 );
light.position.set(1,3,0);

light.add(new THREE.Mesh(new THREE.SphereGeometry(0.1,32,16), new THREE.MeshBasicMaterial({
  color: 'white',
})));
light.castShadow = true;
light.shadow.radius = 1;
scene.add(light);

// setup post processing
const composer = new EffectComposer(renderer);
const renderPass = new RenderPass(scene, camera);

// setup bloom pass
const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 2, 1, 0.4);

// setup FXAA pass
const fxaaPass = new ShaderPass( FXAAShader );
const pixelRatio = renderer.getPixelRatio();
fxaaPass.material.uniforms[ 'resolution' ].value.x = 1 / ( window.innerWidth * pixelRatio );
fxaaPass.material.uniforms[ 'resolution' ].value.y = 1 / ( window.innerHeight * pixelRatio );

// add passes into composer
composer.addPass(renderPass);
composer.addPass(bloomPass);
composer.addPass(fxaaPass);

// setup controller
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;

// add resize event listener
function resize() {
  const width = document.body.clientWidth;
  const height = document.body.clientHeight;

  canvas.width = width;
  canvas.height = height;

  camera.aspect = width / height;
  camera.updateProjectionMatrix();

  renderer.setSize(width, height);
  composer.setSize(width, height);

  fxaaPass.material.uniforms[ 'resolution' ].value.x = 1 / ( window.innerWidth * pixelRatio );
  fxaaPass.material.uniforms[ 'resolution' ].value.y = 1 / ( window.innerHeight * pixelRatio );
}
window.onresize = resize;

resize();

// animate
function animate() {
  requestAnimationFrame(animate);
  composer.render();

  cube.rotateY(0.02);
  controls.update();
}

animate();