본문 바로가기
Graphic/ThreeJS

25 Realistic render

by curious week 2025. 7. 31.

Three.js 고품질 렌더링 가이드 (Rendering Best Practices)

소개

3D 모델을 Three.js로 불러올 때 원하는 결과가 나오지 않는 경우가 많습니다. 예를 들어 햄버거 모델을 처음 불러왔을 때 품질이 낮게 보였는데, 그 원인은 여러 요소들이 복합적으로 작용하기 때문입니다.

이 레슨에서는 Three.js에서 사실적인 렌더링 품질을 높이기 위한 여러 기법을 다룹니다. 하지만 일부 기법은 성능에 영향을 줄 수 있으며, 모델에 따라 다르게 적용해야 하므로 상황에 맞게 조절해야 합니다.

설정 (Setup)

햄버거 대신 좀 더 사실적인 모델을 사용합니다. 예시는 GLTF Sample Models에 포함된 Flight Helmet 모델입니다. 해당 모델은 /static/models/에 위치하며, 이미 로딩되어 씬에 추가되어 있다고 가정합니다.

또한 lil-gui를 사용하여 렌더링 파라미터를 실시간으로 조정할 수 있도록 합니다.

환경맵 (Environment Map)

HDR equirectangular 텍스처를 사용한 환경맵이 이미 설정되어 있습니다. 이 환경맵 덕분에 기본적인 조명과 반사가 적용되어 장면이 더 사실적으로 보입니다.

주의: HDR 환경맵은 품질이 뛰어나지만 파일 크기가 크고 성능에도 영향을 줄 수 있습니다.
배경으로 사용하지 않을 경우에는 해상도를 줄여서 용량과 성능을 최적화할 수 있습니다.

톤 매핑 (Tone Mapping)

톤 매핑은 HDR 데이터를 LDR로 변환하여 화면에 출력하는 과정입니다.
Three.js는 실제 HDR 데이터가 아닌 경우에도 이를 시뮬레이션하여 사실적인 톤을 제공합니다.

사용 가능한 톤 매핑 옵션

renderer.toneMapping = THREE.ACESFilmicToneMapping;

lil-gui에 드롭다운 메뉴를 추가하려면:

gui.add(renderer, 'toneMapping', {
  No: THREE.NoToneMapping,
  Linear: THREE.LinearToneMapping,
  Reinhard: THREE.ReinhardToneMapping,
  Cineon: THREE.CineonToneMapping,
  ACESFilmic: THREE.ACESFilmicToneMapping,
});

톤 매핑 노출값은 다음과 같이 조정할 수 있습니다:

renderer.toneMappingExposure = 3;
gui.add(renderer, 'toneMappingExposure').min(0).max(10).step(0.001);

안티앨리어싱 (Antialiasing)

계단 현상(aliasing)은 화면의 픽셀 그리드와 지오메트리 가장자리가 정렬되지 않을 때 발생합니다. (픽셀 하나를 다 채우지 못하면 픽셀이 계단처럼 보이게 됨.)

(L) aliasing / (R) antialias: ture

해결책

  1. 슈퍼 샘플링(Super Sampling, SSAA): 화면을 더 큰 해상도로 렌더하고 나중에 축소
  2. 멀티 샘플링(Multisample, MSAA): 가장자리에서만 여러 샘플을 평균, 성능에 크게 영향이 없음

Three.js는 antialias: true 옵션으로 자동으로 MSAA를 적용합니다:

const renderer = new THREE.WebGLRenderer({
  canvas: canvas,
  antialias: true, // MSAA 활성화
});

팁: 픽셀 비율이 2 이상(고해상도)이면 안티앨리어싱이 크게 필요하지 않을 수도 있습니다.

const needsAntialias = window.devicePixelRatio < 2;

const renderer = new THREE.WebGLRenderer({
  canvas,
  antialias: needsAntialias,
});

이런 식으로 조건부 처리를 할 수 있습니다.

그림자 (Shadows)

환경맵은 모든 방향에서 빛을 제공하지만 그림자를 만들지는 못합니다. 이를 위해 직접 라이트를 추가해야 합니다.

Directional Light 추가

const directionalLight = new THREE.DirectionalLight('#ffffff', 6);
directionalLight.position.set(3, 7, 6);
scene.add(directionalLight);

lil-gui로 조정:

gui.add(directionalLight, 'intensity', 0, 10, 0.001);
gui.add(directionalLight.position, 'x', -10, 10, 0.001);
gui.add(directionalLight.position, 'y', -10, 10, 0.001);
gui.add(directionalLight.position, 'z', -10, 10, 0.001);

그림자 활성화

renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;

directionalLight.castShadow = true;
gui.add(directionalLight, 'castShadow');

그림자 카메라 타겟 조정

directionalLight.target.position.set(0, 4, 0);
directionalLight.target.updateWorldMatrix();

카메라 헬퍼:

const directionalLightCameraHelper = new THREE.CameraHelper(
  directionalLight.shadow.camera,
);
scene.add(directionalLightCameraHelper);

섀도우 범위/해상도 조정

directionalLight.shadow.camera.far = 15;
directionalLight.shadow.mapSize.set(1024, 1024);

모든 Mesh에 그림자 적용

const updateAllMaterials = () => {
  scene.traverse((child) => {
    if (child.isMesh) {
      child.castShadow = true;
      child.receiveShadow = true;
    }
  });
};

텍스처와 색상 공간 (Color Space)

색상 공간은 사람 눈의 민감도에 맞춰 색을 인코딩하는 방식입니다. 시각적으로 보이는 텍스처(map)는 THREE.SRGBColorSpace로 설정해야 자연스럽게 보입니다:

floorColorTexture.colorSpace = THREE.SRGBColorSpace;
wallColorTexture.colorSpace = THREE.SRGBColorSpace;

Normal, Roughness, AO 등 정보용 텍스처는 기본 Linear Color Space로 유지합니다.

GLTF 모델은 텍스처의 색상 공간을 .glb 안에 저장해오기 때문에 따로 설정하지 않아도 됩니다.

햄버거 모델 적용 및 셀프 섀도우 문제

햄버거 모델을 불러오면 표면에 이상한 줄이 생길 수 있습니다. 이것은 Shadow Acne라 불리는 현상이며, 물체가 자기 자신에게 그림자를 던질 때 생깁니다.

해결 방법:

directionalLight.shadow.normalBias = 0.027;
directionalLight.shadow.bias = -0.004;

값은 lil-gui로 실험하면서 찾는 것이 좋습니다:

gui.add(directionalLight.shadow, 'normalBias', -0.05, 0.05, 0.001);
gui.add(directionalLight.shadow, 'bias', -0.05, 0.05, 0.001);

 

이번 레슨에서는 사실적인 렌더링을 위한 기본적인 기법들을 다루었습니다:

  • HDR 환경맵
  • 톤 매핑
  • 안티앨리어싱
  • 그림자 처리
  • 색상 공간

이후에는 블룸, 피사계 심도, 모션 블러, 후처리(post-processing) 같은 고급 기법도 공부할 수 있습니다.


import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import GUI from 'lil-gui';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader.js';

/**
 * Loaders
 */
const gltfLoader = new GLTFLoader();
const rgbeLoader = new RGBELoader();
const textureLoader = new THREE.TextureLoader();

/**
 * Base
 */
// Debug
const gui = new GUI();

// Canvas
const canvas = document.querySelector('canvas.webgl');

// Scene
const scene = new THREE.Scene();

/**
 * Update all materials
 */
const updateAllMaterials = () => {
  scene.traverse((child) => {
    if (child.isMesh) {
      child.castShadow = true;
      child.receiveShadow = true;
    }
  });
};

/**
 * Environment map
 */
// Intensity
scene.environmentIntensity = 1;
gui.add(scene, 'environmentIntensity').min(0).max(10).step(0.001);

// HDR (RGBE) equirectangular
rgbeLoader.load('/environmentMaps/0/2k.hdr', (environmentMap) => {
  environmentMap.mapping = THREE.EquirectangularReflectionMapping;

  scene.background = environmentMap;
  scene.environment = environmentMap;
});

/**
 * Directional Light
 */
const directionalLight = new THREE.DirectionalLight('#ffffff', 6);
directionalLight.position.set(-4, 6.5, 2.5);

scene.add(directionalLight);

gui
  .add(directionalLight, 'intensity')
  .min(0)
  .max(10)
  .step(0.001)
  .name('lightIntensity');
gui
  .add(directionalLight.position, 'x')
  .min(-10)
  .max(10)
  .step(0.001)
  .name('lightX');
gui
  .add(directionalLight.position, 'y')
  .min(-10)
  .max(10)
  .step(0.001)
  .name('lightY');
gui
  .add(directionalLight.position, 'z')
  .min(-10)
  .max(10)
  .step(0.001)
  .name('lightZ');

// Shadow
directionalLight.castShadow = true;
directionalLight.shadow.camera.far = 15;
directionalLight.shadow.mapSize.set(512, 512); // 원하는 성능에 따라 해상도 조절
directionalLight.shadow.normalBias = 0.027;
directionalLight.shadow.bias = -0.004;
gui.add(directionalLight, 'castShadow');
gui.add(directionalLight.shadow, 'normalBias').min(-0.05).max(0.05).step(0.001);
gui.add(directionalLight.shadow, 'bias').min(-0.05).max(0.05).step(0.001);

// Helper
// const directionalLightHelper = new THREE.CameraHelper(
//   directionalLight.shadow.camera,
// );
// scene.add(directionalLightHelper);

// Target
directionalLight.target.position.set(0, 4, 0);
// scene.add(directionalLight.target); // target.updateWorldMatrix()과 동일
directionalLight.target.updateWorldMatrix();

/**
 * Models
 */
// Helmet
// gltfLoader.load('/models/FlightHelmet/glTF/FlightHelmet.gltf', (gltf) => {
//   gltf.scene.scale.set(10, 10, 10);
//   scene.add(gltf.scene);

//   updateAllMaterials();
// });

// Hamburger
gltfLoader.load('/models/hamburger.glb', (glTF) => {
  glTF.scene.scale.set(0.4, 0.4, 0.4);
  glTF.scene.position.set(0, 2.5, 0);
  scene.add(glTF.scene);
});

/**
 * Floor
 */
const floorColorTexture = textureLoader.load(
  '/textures/wood_cabinet_worn_long/wood_cabinet_worn_long_diff_1k.jpg',
);
const floorNormalTexture = textureLoader.load(
  '/textures/wood_cabinet_worn_long/wood_cabinet_worn_long_nor_gl_1k.jpg',
);
const floorAORoughnessMetalnessTexture = textureLoader.load(
  '/textures/wood_cabinet_worn_long/wood_cabinet_worn_long_arm_1k.jpg',
);

floorColorTexture.colorSpace = THREE.SRGBColorSpace;

const floor = new THREE.Mesh(
  new THREE.PlaneGeometry(8, 8),
  new THREE.MeshStandardMaterial({
    map: floorColorTexture,
    normalMap: floorNormalTexture,
    aoMap: floorAORoughnessMetalnessTexture,
    roughnessMap: floorAORoughnessMetalnessTexture,
    metalnessMap: floorAORoughnessMetalnessTexture,
  }),
);
floor.rotation.x = -Math.PI * 0.5;
scene.add(floor);

/**
 * Wall
 */
const wallColorTexture = textureLoader.load(
  '/textures/castle_brick_broken_06/castle_brick_broken_06_diff_1k.jpg',
);
const wallNormalTexture = textureLoader.load(
  '/textures/castle_brick_broken_06/castle_brick_broken_06_nor_gl_1k.jpg',
);
const wallAORoughnessMetalnessTexture = textureLoader.load(
  '/textures/castle_brick_broken_06/castle_brick_broken_06_arm_1k.jpg',
);

wallColorTexture.colorSpace = THREE.SRGBColorSpace;

const wall = new THREE.Mesh(
  new THREE.PlaneGeometry(8, 8),
  new THREE.MeshStandardMaterial({
    map: wallColorTexture,
    normalMap: wallNormalTexture,
    aoMap: wallAORoughnessMetalnessTexture,
    roughnessMap: wallAORoughnessMetalnessTexture,
    metalnessMap: wallAORoughnessMetalnessTexture,
  }),
);
wall.position.y = 4;
wall.position.z = -4;
scene.add(wall);

/**
 * Sizes
 */
const sizes = {
  width: window.innerWidth,
  height: window.innerHeight,
};

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

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

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

/**
 * Camera
 */
// Base camera
const camera = new THREE.PerspectiveCamera(
  75,
  sizes.width / sizes.height,
  0.1,
  100,
);
camera.position.set(4, 5, 4);
scene.add(camera);

// Controls
const controls = new OrbitControls(camera, canvas);
controls.target.y = 3.5;
controls.enableDamping = true;

/**
 * Renderer
 */
const renderer = new THREE.WebGLRenderer({
  canvas: canvas,
  antialias: true, // 픽셀 비율이 2 이상(고해상도)이면 안티앨리어싱이 크게 필요하지 않을 수도 있습니다.
});
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

// Tone mapping
renderer.toneMapping = THREE.ReinhardToneMapping;
renderer.toneMappingExposure = 2; // 빛을 얼마나 받아 들이는가

gui.add(renderer, 'toneMapping', {
  No: THREE.NoToneMapping, // default
  Linear: THREE.LinearToneMapping,
  Reinhard: THREE.ReinhardToneMapping,
  Cineon: THREE.CineonToneMapping,
  ACESFilmic: THREE.ACESFilmicToneMapping,
});
gui.add(renderer, 'toneMappingExposure').min(0).max(10).step(0.01);

// Shadows
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;

/**
 * Animate
 */
const tick = () => {
  // Update controls
  controls.update();

  // Render
  renderer.render(scene, camera);

  // Call tick again on the next frame
  window.requestAnimationFrame(tick);
};

tick();

'Graphic > ThreeJS' 카테고리의 다른 글

27 Shaders  (5) 2025.08.02
26. 프로젝트 구조화(Code structuring for bigger projects)  (4) 2025.08.02
24 Environment Map  (3) 2025.07.31
22 Raycaster와 MouseEvent  (3) 2025.07.29
21 모델 불러오기(Imported models)  (4) 2025.07.29