본문 바로가기
Graphic/ThreeJS

45 후처리(Post-Processing)

by curious week 2025. 8. 6.

Three.js Post-processing

후처리는 렌더링된 이미지에 추가적인 시각 효과를 적용하는 기법으로, 영화나 게임, 고품질 시각화에 자주 사용됩니다. Three.js에서도 이 후처리를 활용할 수 있으며, 그 핵심 도구는 EffectComposer입니다.


🎯 후처리로 할 수 있는 효과들

  • 피사계 심도 (Depth of Field)
  • Bloom (꽃)
  • God Rays (신의 광선)
  • Motion Blur (모션 블러)
  • Glitch (글리치 효과)
  • Color Grading (색 보정)
  • Anti-Aliasing (앤티앨리어싱)
  • Reflection & Refraction (반사/굴절)

📦 기본 구조와 작동 원리

1. 렌더 타겟(Render Target)

Three.js에서는 후처리를 위해 실제 캔버스가 아니라 RenderTarget이라는 텍스처에 장면을 먼저 렌더링합니다.

  • 이 텍스처는 이후 패스들에서 입력으로 사용됩니다.
  • WebGL에서는 흔히 Framebuffer 혹은 Buffer라고도 부릅니다.

2. Ping-Pong 버퍼링

  • 다중 패스(Post-Processing Pass)를 사용할 때, 하나의 버퍼로만 렌더링하면 동시에 읽고 쓸 수 없어 오류가 납니다.
  • 그래서 두 개의 RenderTarget을 번갈아가며 쓰고 읽는 핑퐁(Ping-Pong) 구조가 필요합니다.

3. 최종 패스는 캔버스에 출력

  • 마지막 패스는 canvas에 직접 렌더링되어 유저가 화면에서 결과를 볼 수 있도록 합니다.

🧱 EffectComposer 설정법

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

const effectComposer = new EffectComposer(renderer);
effectComposer.setSize(sizes.width, sizes.height);
effectComposer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

const renderPass = new RenderPass(scene, camera);
effectComposer.addPass(renderPass);

🔁 애니메이션 루프에서 사용

const tick = () => {
  // effectComposer가 최종 렌더링 담당
  effectComposer.render();
  requestAnimationFrame(tick);
};

🔍 패스(Pass) 적용 예시

1. 🎯 DotScreenPass

import { DotScreenPass } from 'three/examples/jsm/postprocessing/DotScreenPass.js';
const dotScreenPass = new DotScreenPass();
effectComposer.addPass(dotScreenPass);

2. 🎯 GlitchPass (화면이 깨진 듯한 효과)

glitchPass.goWild = false; / glitchPass.goWild = true;

import { GlitchPass } from 'three/examples/jsm/postprocessing/GlitchPass.js';
const glitchPass = new GlitchPass();
effectComposer.addPass(glitchPass);

3. 🎯 RGBShiftPass (Shader 기반)

어둡고 뿌옇게 변한다.

import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js';
import { RGBShiftShader } from 'three/examples/jsm/shaders/RGBShiftShader.js';
const rgbShiftPass = new ShaderPass(RGBShiftShader);
effectComposer.addPass(rgbShiftPass);

4. 🎯 GammaCorrectionPass (색상 고정)

GammaCorrectionPass와 함께 사용 시 (색상은 고정되고 효과만 유지된다.): 

적용 전 / 적용 후

import { GammaCorrectionShader } from 'three/examples/jsm/shaders/GammaCorrectionShader.js';
const gammaCorrectionPass = new ShaderPass(GammaCorrectionShader);
effectComposer.addPass(gammaCorrectionPass);

5. 🎯 SMAA (Anti-Aliasing (AA, 앤티앨리어싱) = 그래픽에서 경계선이 매끄럽지 않고 ‘계단처럼 톱니 모양으로 보이는 현상)

renderTarget 적용 전과 후

// Render target (후처리를 위해 씬을 캔버스가 아닌 텍스처로 렌더링할 때 사용)
const renderTarget = new THREE.WebGLRenderTarget(800, 600, {
  samples: 2, // MSAA (Multisample Anti-Aliasing)를 활성화
});

// Effect composer
const effectComposer = new EffectComposer(renderer, renderTarget);

WebGL2에서만 동작하고, WebGL1에서는 무시됨.

console.log(renderer.capabilities); // webgl 버전을 확인할 수 있다.

import { SMAAPass } from 'three/examples/jsm/postprocessing/SMAAPass.js';
const smaaPass = new SMAAPass();
effectComposer.addPass(smaaPass);

조건부로 추가할 수도 있음:

if (renderer.getPixelRatio() === 1 && !renderer.capabilities.isWebGL2) {
  effectComposer.addPass(smaaPass);
}

6. 🎯 UnrealBloomPass

조정 전 / 조정 후

import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';
const unrealBloomPass = new UnrealBloomPass();
effectComposer.addPass(unrealBloomPass);

조정

// Unreal bloom pass
const unrealBloomPass = new UnrealBloomPass();
unrealBloomPass.strength = 0.3;
unrealBloomPass.radius = 1;
unrealBloomPass.threshold = 0.6;
effectComposer.addPass(unrealBloomPass);

조정용 GUI

gui.add(unrealBloomPass, 'enabled');
gui.add(unrealBloomPass, 'strength').min(0).max(2).step(0.001);
gui.add(unrealBloomPass, 'radius').min(0).max(2).step(0.001);
gui.add(unrealBloomPass, 'threshold').min(0).max(1).step(0.001);

🛠 커스텀 패스 만들기

1. 🔴 TintShader - 색상 보정

const TintShader = {
  uniforms: {
    tDiffuse: { value: null },
    uTint: { value: null },
  },
  vertexShader: `
    varying vec2 vUv;

    void main() {
        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);

        vUv = uv;
    }
  `,
  fragmentShader: `
    uniform sampler2D tDiffuse;
    uniform vec3 uTint;

    varying vec2 vUv;

    void main() {
        vec4 color = texture2D(tDiffuse, vUv);
        color.rgb += uTint;

        gl_FragColor = color;
    }
  `,
};
const tintPass = new ShaderPass(TintShader);
tintPass.material.uniforms.uTint.value = new THREE.Vector3();
effectComposer.addPass(tintPass);

gui.add(tintPass.material.uniforms.uTint.value, 'x').min(-1).max(1).step(0.001).name('red');
gui.add(tintPass.material.uniforms.uTint.value, 'y').min(-1).max(1).step(0.001).name('green');
gui.add(tintPass.material.uniforms.uTint.value, 'z').min(-1).max(1).step(0.001).name('blue');

2. 🌊 DisplacementShader - 변위

interfaceNormalMap.png / interfaceNormalMap.png 적용

const DisplacementShader = {
  uniforms: {
    tDiffuse: { value: null },
    uTime: { value: 0.0 },
    uNormalMap: {
      value: textureLoader.load('/textures/interfaceNormalMap.png'),
    },
  },
  vertexShader: `
    varying vec2 vUv;
    void main() {
      vUv = uv;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `,
  fragmentShader: `
    uniform sampler2D tDiffuse;
    uniform float uTime;
    uniform sampler2D uNormalMap;
    varying vec2 vUv;

    void main() {
      vec3 normalColor = texture2D(uNormalMap, vUv).xyz * 2.0 - 1.0;
      vec2 newUv = vUv + normalColor.xy * 0.1;
      vec4 color = texture2D(tDiffuse, newUv);

      vec3 lightDirection = normalize(vec3(-1.0, 1.0, 0.0));
      float lightness = clamp(dot(normalColor, lightDirection), 0.0, 1.0);
      color.rgb += lightness * 2.0;

      gl_FragColor = color;
    }
  `,
};

📐 리사이즈 대응

renderer에 리사이즈 대응을 했더라도, effectComposer에 리사이즈 대응을 하지 않으면 아래처럼 픽셀이 깨지게 된다.

window.addEventListener('resize', () => {
  sizes.width = window.innerWidth;
  sizes.height = window.innerHeight;

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

  renderer.setSize(sizes.width, sizes.height);
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

  effectComposer.setSize(sizes.width, sizes.height);
  effectComposer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
});

✅ 정리

  • EffectComposer는 후처리를 위한 중심 객체입니다.
  • 패스를 조합해 다양한 시각 효과를 얻을 수 있습니다.
  • 커스텀 패스를 만들어 자신만의 후처리를 적용할 수도 있습니다.
  • 성능을 고려해 적절한 패스만 적용하세요. (프레임 당 적용되므로 성능저하)

🔗 참고: Three.js EffectComposer 문서


import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import {
  DotScreenPass,
  RenderPass,
  EffectComposer,
  GlitchPass,
  RGBShiftShader,
  ShaderPass,
  GammaCorrectionShader,
  SMAAPass,
  UnrealBloomPass,
} from 'three/examples/jsm/Addons.js';
import GUI from 'lil-gui';

/**
 * Base Setup
 */
const gui = new GUI(); // 디버깅 UI 생성
const canvas = document.querySelector('canvas.webgl'); // 렌더링 대상 캔버스
const scene = new THREE.Scene(); // 장면 생성

/**
 * Loaders
 */
const gltfLoader = new GLTFLoader();
const cubeTextureLoader = new THREE.CubeTextureLoader();
const textureLoader = new THREE.TextureLoader();

/**
 * Update all standard materials in the scene
 */
const updateAllMaterials = () => {
  scene.traverse((child) => {
    if (
      child instanceof THREE.Mesh &&
      child.material instanceof THREE.MeshStandardMaterial
    ) {
      child.material.envMapIntensity = 2.5;
      child.material.needsUpdate = true;
      child.castShadow = true;
      child.receiveShadow = true;
    }
  });
};

/**
 * Environment map 설정 (IBL 조명에 사용됨)
 */
const environmentMap = cubeTextureLoader.load([
  '/textures/environmentMaps/0/px.jpg',
  '/textures/environmentMaps/0/nx.jpg',
  '/textures/environmentMaps/0/py.jpg',
  '/textures/environmentMaps/0/ny.jpg',
  '/textures/environmentMaps/0/pz.jpg',
  '/textures/environmentMaps/0/nz.jpg',
]);

scene.background = environmentMap;
scene.environment = environmentMap;

/**
 * 모델 로딩 (Damaged Helmet)
 */
gltfLoader.load('/models/DamagedHelmet/glTF/DamagedHelmet.gltf', (gltf) => {
  gltf.scene.scale.set(2, 2, 2);
  gltf.scene.rotation.y = Math.PI * 0.5;
  scene.add(gltf.scene);
  updateAllMaterials();
});

/**
 * Directional Light 설정
 */
const directionalLight = new THREE.DirectionalLight('#ffffff', 3);
directionalLight.castShadow = true;
directionalLight.shadow.mapSize.set(1024, 1024);
directionalLight.shadow.camera.far = 15;
directionalLight.shadow.normalBias = 0.05;
directionalLight.position.set(0.25, 3, -2.25);
scene.add(directionalLight);

/**
 * 반응형 처리
 */
const sizes = {
  width: window.innerWidth,
  height: window.innerHeight,
};

window.addEventListener('resize', () => {
  sizes.width = window.innerWidth;
  sizes.height = window.innerHeight;
  camera.aspect = sizes.width / sizes.height;
  camera.updateProjectionMatrix();
  renderer.setSize(sizes.width, sizes.height);
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  effectComposer.setSize(sizes.width, sizes.height);
  effectComposer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
});

/**
 * 카메라 및 OrbitControls
 */
const camera = new THREE.PerspectiveCamera(75, sizes.width / sizes.height, 0.1, 100);
camera.position.set(4, 1, -4);
scene.add(camera);

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

/**
 * 렌더러 설정
 */
const renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true });
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;
renderer.toneMapping = THREE.ReinhardToneMapping;
renderer.toneMappingExposure = 1.5;
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

/**
 * 포스트 프로세싱
 */
const renderTarget = new THREE.WebGLRenderTarget(800, 600, {
  // WebGL2에서는 MSAA 지원 가능
  // samples: renderer.getPixelRatio() === 1 ? 2 : 0,
});

const effectComposer = new EffectComposer(renderer, renderTarget);
effectComposer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
effectComposer.setSize(sizes.width, sizes.height);

// 기본 렌더링 패스
const renderPass = new RenderPass(scene, camera);
effectComposer.addPass(renderPass);

// 점 패턴 패스
const dotScreenPass = new DotScreenPass();
dotScreenPass.enabled = false;
effectComposer.addPass(dotScreenPass);

// 글리치 효과
const glitchPass = new GlitchPass();
glitchPass.goWild = false;
glitchPass.enabled = false;
effectComposer.addPass(glitchPass);

// RGB 색상 분리 효과
const rgbShiftPass = new ShaderPass(RGBShiftShader);
rgbShiftPass.enabled = false;
effectComposer.addPass(rgbShiftPass);

// 감마 보정 (색상 왜곡 방지)
const gammaCorrectionPass = new ShaderPass(GammaCorrectionShader);
effectComposer.addPass(gammaCorrectionPass);

// 블룸 효과 (빛 번짐 효과)
const unrealBloomPass = new UnrealBloomPass();
unrealBloomPass.strength = 0.3;
unrealBloomPass.radius = 1;
unrealBloomPass.threshold = 0.6;
effectComposer.addPass(unrealBloomPass);

gui.add(unrealBloomPass, 'enabled');
gui.add(unrealBloomPass, 'strength').min(0).max(2).step(0.001);
gui.add(unrealBloomPass, 'radius').min(0).max(2).step(0.001);
gui.add(unrealBloomPass, 'threshold').min(0).max(1).step(0.001);

// 색상 보정용 틴트 패스
const TintShader = {
  uniforms: {
    tDiffuse: { value: null },
    uTint: { value: null },
  },
  vertexShader: `
    varying vec2 vUv;
    void main() {
        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
        vUv = uv;
    }
  `,
  fragmentShader: `
    uniform sampler2D tDiffuse;
    uniform vec3 uTint;
    varying vec2 vUv;
    void main() {
        vec4 color = texture2D(tDiffuse, vUv);
        color.rgb += uTint;
        gl_FragColor = color;
    }
  `,
};
const tintPass = new ShaderPass(TintShader);
tintPass.material.uniforms.uTint.value = new THREE.Vector3();
effectComposer.addPass(tintPass);

gui.add(tintPass.material.uniforms.uTint.value, 'x').min(-1).max(1).step(0.001).name('red');
gui.add(tintPass.material.uniforms.uTint.value, 'y').min(-1).max(1).step(0.001).name('green');
gui.add(tintPass.material.uniforms.uTint.value, 'z').min(-1).max(1).step(0.001).name('blue');

// 노말맵 기반 UV 변형 디스플레이스 패스
const DisplacementShader = {
  uniforms: {
    tDiffuse: { value: null },
    uNormalMap: { value: null },
  },
  vertexShader: `
    varying vec2 vUv;
    void main() {
        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
        vUv = uv;
    }
  `,
  fragmentShader: `
    uniform sampler2D tDiffuse;
    uniform sampler2D uNormalMap;
    varying vec2 vUv;
    void main() {
        vec3 normalColor = texture2D(uNormalMap, vUv).xyz * 2.0 - 1.0;
        vec2 newUv = vUv + normalColor.xy * 0.1;
        vec4 color = texture2D(tDiffuse, newUv);
        vec3 lightDirection = normalize(vec3(-1.0, 1.0, 0.0));
        float lightness = clamp(dot(normalColor, lightDirection), 0.0, 1.0);
        color.rgb += lightness * 2.0;
        gl_FragColor = color;
    }
  `,
};
const displacementPass = new ShaderPass(DisplacementShader);
displacementPass.material.uniforms.uNormalMap.value = textureLoader.load('/textures/interfaceNormalMap.png');
effectComposer.addPass(displacementPass);

// SMAA 안티앨리어싱 패스 (WebGL1에서만 동작)
if (renderer.getPixelRatio() === 1 && !renderer.capabilities.isWebGL2) {
  const smaaPass = new SMAAPass();
  effectComposer.addPass(smaaPass);
  console.log('Using WebGL 1');
}

/**
 * 애니메이션 루프
 */
const clock = new THREE.Clock();
const tick = () => {
  const elapsedTime = clock.getElapsedTime();

  // Pass 업데이트
  // displacementPass.material.uniforms.uTime.value = elapsedTime;

  controls.update();
  effectComposer.render();
  window.requestAnimationFrame(tick);
};

tick();