태양에 빛번짐 (후광) 효과 추가하기

0

이 글은 여러 개의 EffectComposer와 레이어(layer)를 이용해 Three.js 장면의 특정 객체에만 블룸(bloom) 효과를 적용하는 방법을 설명한다. Three.js의 Layers를 활용하여 블룸 효과가 필요한 객체들을 전용 레이어로 분리한다. “블룸 컴포저(bloom composer)”는 이 객체들만 블룸 효과와 함께 렌더링하고, “메인 컴포저(main composer)”는 전체 장면을 렌더링한 뒤 커스텀 셰이더를 이용해 블룸 효과를 다시 합친다. 두 렌더링 이미지를 제대로 합치기 위해, 블룸하지 않을 객체들은 블룸 렌더링 과정 동안 일시적으로 어둡게 만든다. 그런 다음 최종 장면을 렌더링하기 전에 원래 재질을 복원한다. 이러한 방식으로 선택적 후처리를 적용할 수 있다.

이 글은 태양계 시뮬레이터 시리즈의 일부다:

  1. Three.js 좌표계 기초
  2. Three.js PBR (물리 기반 렌더링) 재질 기초
  3. 사실적인 지구 만들기
  4. 사실적인 태양 만들기
  5. 고리가 있는 행성 만들기
  6. 불규칙한 형태의 위성 만들기
  7. 태양에 후광 효과 추가하기
  8. 은하수 배경 만들기
  9. 천체 궤도 계산하기

지금까지 쓴 글들을 바탕으로, Three.js로 사실적인 태양계를 만들 수 있었다. 시각적인 사실감을 더하기 위해, 카메라나 사람의 눈으로 볼 때 일정 밝기 이상의 빛이 빛나는, 빛번짐이 발생하는 블룸 효과를 구현한다. 그러나 장면 전체에 블룸 효과를 적용하면 행성이나 별 같은 객체들도 빛나게 되어 보기에 그럴듯 하지 않다. 이 글에서는 태양에만 블룸 효과를 적용하는 방법을 설명한다. 이렇게 하면 태양계를 더 깔끔하고 시각적으로 매력있게 만들 수 있다.

선택적 블룸 효과는 여러 개의 EffectComposer와 레이어를 사용하여 적용할 수 있다. 특정 객체에 블룸 효과를 적용하는 단계는 다음과 같다.

분리를 위한 레이어 사용

Three.js의 Layers는 장면의 오브젝트를 선택적으로 렌더링할 수 있게 한다. 32개의 레이어(0-31)가 있으며, 객체는 그중 하나 이상에 속할 수 있다. 레이어는 비트마스크(bit-mask)로 표현된다.

  • 레이어 0: 1<<0
  • 레이어 1: 1<<1
  • 레이어 0과 1: 1<<0 || 1<<1
  • 레이어 31: 1<<31

여러 레이어에 할당된 객체는 해당 레이어 값들의 합과 같은 비트마스크를 가진다. 예를 들어 레이어 0과 1에 있는 객체는 비트마스크 $1 + 2 = 3$을 가진다. 레이어가 할당되지 않으면 비트마스크는 0이 되어 객체는 보이지 않는다. 카메라 객체도 Layers 속성을 가진다. 카메라는 자신과 적어도 하나의 레이어를 공유하는 객체만 렌더링한다. 기본적으로 카메라와 객체 모두 레이어 0에 속한다. 객체는 layers 속성을 이용해 다른 레이어에 할당할 수 있다.

obj = new THREE.Mesh({}, {}); // default layer is 0, 0x000...0001
obj.layers.set(1); // 0x000...0010
obj.layers.enable(0); // 0x000...0011

이제 태양계로 돌아가서, 태양을 전용 블룸 레이어(이 경우 레이어 1)에 할당한다:

sun.layers.enable(1);

이 설정은 블룸 효과가 태양에만 적용되도록 보장한다. 다음으로, 렌더링을 처리하기 위해 두 개의 EffectComposer 인스턴스를 구성한다. 하나는 블룸 효과용, 다른 하나는 최종 장면용이다.

Effect composer 설정

블룸 컴포저

블룸 컴포저는 블룸 효과가 필요한 객체들만 렌더링한다. 여기에는 renderPass, antialiasPass, bloomPass가 포함되며, 화면에 직접 렌더링하지 않는다. 대신 그 렌더링 결과를 메인 컴포저에 인자로 전달한다.

bloomComposer = new EffectComposer(renderer);
bloomComposer.addPass(renderPass);
bloomComposer.addPass(antialiasPass);
bloomComposer.addPass(bloomPass);
bloomComposer.renderToScreen = false;

블룸 컴포저를 렌더링하기 전에, 먼저 블룸하지 않을 객체들을 어둡게 만들고, 빛을 가리지 않는 객체(즉, 대기, 글로우)는 보이지 않게 만든다. 이러한 재질 변경은 블룸 효과 처리 후에 복원된다.

블룸하지 않는 객체 숨기기

블룸 효과를 렌더링하기 전에, 블룸하지 않는 객체들은 그 재질을 검은색 단색 재질로 교체하여 “어둡게” 만들어, 블룸 컴포저가 이 객체들에 영향을 주지 못하게 한다. 이 함수는 블룸 레이어1에 있는 객체들에만 블룸 효과를 적용하도록 한다. 어둡게 만들 객체의 원래 재질을 임시로 저장하기 위해 materials 리스트 변수를 사용한다는 점에 유의한다.

function darkenNonBloom(obj) {
  // Hide non-blooming and non-occluding object
  if ( obj.name == 'atmosphere' || obj.name == 'glow' || obj.name == 'fresnel')
    obj.visible = false;

    // Darken non-blooming and occluding object
  else if ( obj.layers.isEnabled(1) === false ) {
    materials[obj.uuid] = obj.material;
    obj.material = darkMaterial;
  }
}

원래 재질 복원

블룸 컴포저를 렌더링한 후, 블룸하지 않는 객체들의 원래 재질을 material 리스트 변수로부터 복원한다.

function restoreMaterial(obj) {
  // Reveal non-blooming and non-occluding object
  if ( obj.name == 'atmosphere' || obj.name == 'glow' || obj.name == 'fresnel')
    obj.visible = true;

    // Restore non-blooming and occluding object
  else if (materials[obj.uuid]) {
    obj.material = materials[obj.uuid];
    delete materials[obj.uuid];
  }
}

메인 컴포저

반면, 메인 컴포저는 전체 장면을 렌더링하고 블룸 효과를 그 안에 합친다. 여기에는 renderPass, antialiasPass, mergePass가 포함된다. mergePass는 메인 컴포저의 주 pass로, 블룸 효과가 있는 렌더링 결과(블룸 컴포저)와 없는 렌더링 결과(메인 컴포저)를 셰이더를 이용해 합친다.

composer = new EffectComposer(renderer);
composer.addPass(renderPass);
composer.addPass(antialiasPass);
composer.addPass(mergePass);

병합 셰이더

블룸 효과를 최종 장면에 다시 합치기 위해 커스텀 셰이더를 사용한다. 이 셰이더는 기본 장면 텍스처(composerantialiasPass 이후 결과)와 블룸 텍스처(bloomComposer의 최종 결과)를 결합한다.

const bloomUniforms = {
  u_baseTexture: { value: null },
  u_bloomTexture: { value: bloomComposer.readBuffer.texture },
  u_alpha: { value: 0.5 }
};
  
const mergePass = new ShaderPass(new THREE.ShaderMaterial({
  uniforms: bloomUniforms,
  vertexShader: shader_pass_vertex,
  fragmentShader: shader_pass_fragment,
}), 'u_baseTexture'); // textureID of the previous rendering texture

ShaderPass는 이전 렌더링 결과를 두 번째 인자로 전달한다. 또한 composer의 렌더링 결과는 readBuffer.texture를 이용해 얻을 수 있다. 이제 shader_pass_fragment가 두 텍스처를 수학적으로 합친다.

// vertex shader
varying vec2 vUv;
void main() {
  vUv = uv;
  gl_Position = vec4(position, 1.0);
}
// fragment shader
uniform sampler2D u_baseTexture;
uniform sampler2D u_bloomTexture;
uniform float u_alpha;

varying vec2 vUv;

void main() {
  gl_FragColor = (texture2D(u_baseTexture, vUv) + u_alpha * texture2D(u_bloomTexture, vUv));
}

최종 렌더링

effect composer를 렌더링하기 위해 renderer.render()를 다음으로 대체한다.

scene.traverse( darkenNonBloom ); // Leave objects that affect blooming
bloomComposer.render(); // Blooming

scene.traverse( restoreMaterial ); // Restore non-blooming objects
composer.render(); // Merge layers 0 (non-bloom) and 1 (bloom)

scene.traverse() 메서드는 장면의 모든 오브젝트를 순회하며 콜백 함수를 실행한다. 비교를 위해, 1) 후처리 없음, 2) 선택적 블룸 효과, 3) 블룸 컴포저만 적용한 렌더링 결과를 보인다.

후처리 없음 선택적 블룸 전체 블룸

예제 코드

아래에서는 단순화된 두 객체(태양과 지구)로 선택적 블룸 후처리를 테스트하기 위한 코드를 제공한다.

예제 코드
import * as THREE from 'three'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { FXAAShader } from 'three/addons/shaders/FXAAShader.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
import shader_pass_fragment from './assets/shaderPass.frag.js';
import shader_pass_vertex from './assets/shaderPass.vert.js';

const canvas = document.createElement("canvas");
canvas.style.backgroundColor = 'black';
document.body.style.margin = '0';
document.body.appendChild(canvas);

const renderer = new THREE.WebGLRenderer({canvas: canvas, alpha: true, antialias: false});
renderer.shadowMap.enabled = true;

const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 5000);
camera.position.set(10,0,0.2);

// create sphere geometry for Sun and Earth
const geometry_sphere = new THREE.SphereGeometry(1, 30, 30); 

// object Sun uses basicMaterial since it emits orange light
const material_sun = new THREE.MeshBasicMaterial({color: 0xffaa00});
const sun = new THREE.Mesh(geometry_sphere, material_sun);
sun.layers.enable(1);
sun.position.set(0, 0, 0);

const light = new THREE.PointLight(0xffffff, 50);
light.position.set(0, 0, 0);
sun.add(light);

// object Earth uses lambertMaterial since it reflects sunlight
const material_earth = new THREE.MeshLambertMaterial({color: 0x4444ff});
const earth = new THREE.Mesh(geometry_sphere, material_earth);
earth.position.set(3, 0, 0); // distant from Sun
earth.scale.set(0.2, 0.2, 0.2); // smaller size than Sun

const earth_plane = new THREE.Object3D();
earth_plane.add(earth);

const scene = new THREE.Scene();
scene.add(sun);
scene.add(earth_plane);

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

let materials = {};

const darkMaterial = new THREE.MeshBasicMaterial({ color: 'black' });

const renderPass = new RenderPass(scene, camera);
const antialiasPass = new ShaderPass(FXAAShader);
const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight));
bloomPass.threshold = 0;
bloomPass.strength = 1.5;
bloomPass.radius = .1;

const bloomComposer = new EffectComposer(renderer);
bloomComposer.addPass(renderPass);
bloomComposer.addPass(bloomPass);
bloomComposer.addPass(antialiasPass);
bloomComposer.renderToScreen = false;

const bloomUniforms = {
  u_baseTexture: { value: null },
  u_bloomTexture: { value: bloomComposer.readBuffer.texture },
  u_alpha: { value: 0.2 }
};

const mergePass = new ShaderPass(new THREE.ShaderMaterial({
  uniforms: bloomUniforms,
  vertexShader: shader_pass_vertex,
  fragmentShader: shader_pass_fragment,
}), 'u_baseTexture');

const composer = new EffectComposer(renderer);
composer.addPass(renderPass);
composer.addPass(antialiasPass);
composer.addPass(mergePass);

function darkenNonBloom(obj) {
  // Hide non-blooming and non-occluding object
  if ( obj.name == 'atmosphere' || obj.name == 'glow' || obj.name == 'fresnel')
    obj.visible = false;

    // Darken non-blooming and occluding object
  else if ( obj.layers.isEnabled(1) === false ) {
    materials[obj.uuid] = obj.material;
    obj.material = darkMaterial;
  }
}

function restoreMaterial(obj) {
  // Reveal non-blooming and non-occluding object
  if ( obj.name == 'atmosphere' || obj.name == 'glow' || obj.name == 'fresnel')
    obj.visible = true;

    // Restore non-blooming and occluding object
  else if (materials[obj.uuid]) {
    obj.material = materials[obj.uuid];
    delete materials[obj.uuid];
  }
}

function resize () {
  const dpr = window.devicePixelRatio;
  const width = document.body.clientWidth;
  const height = document.body.clientHeight;

  canvas.width = width * dpr;
  canvas.height = height * dpr;

  renderer.setSize(width, height);

  camera.aspect = width / height;
  camera.updateProjectionMatrix();
  
  bloomComposer.setSize(width, height);
  composer.setSize(width, height);
  
  antialiasPass.material.uniforms['resolution'].value.x = 1 / canvas.width;
  antialiasPass.material.uniforms['resolution'].value.y = 1 / canvas.height;
}
window.onresize = resize;
resize();

function animate () {
  requestAnimationFrame(animate);

  controls.update();

  earth_plane.rotateY(0.01);
  
  scene.traverse( darkenNonBloom ); // Leave objects that affect blooming
  bloomComposer.render(); // Blooming
  
  scene.traverse( restoreMaterial ); // Restore non-blooming objects
  composer.render(); // Merge layers 0 (non-bloom) and 1 (bloom)
}
animate();