개요
3D 세계에 가장 중요한 요소는 바로 빛과 그림자입니다. 아무리 멋진 모델이라도 조명이 없으면 그저 검은 화면에 불과하죠. 이번 포스팅에는 Three.js의 사실감을 더하는 그림자를 효율적으로 구현하는 방법까지 깊이 있게 다뤄보겠습니다.
그림자의 기본 원리
Three.js에서 그림자는 '섀도우 맵(Shadow Map)' 이라는 기술을 통해 구현됩니다. 원리는 생각보다 간단합니다.
- 광원의 위치에서 장면을 바라봅니다.
- 광원에게 보이는 물체까지의 거리를 흑백 이미지(텍스처)로 저장합니다.(섀도우 맵)
- 최종적으로 장면을 렌더링 할 때, 특정 픽셀이 광원에 가려졌는지 이 섀도우 맵을 보고 판단하여 그림자를 그립니다.
이러한 원리 때문에, 우리는 renderer, light, 그리고 각 객체에 '그림자'와 관련된 설정을 해주어야 합니다.
그림자 구현을 위한 필수 설정
Three.js에서 그림자를 구현하는 과정은 아래 4가지 단계를 거칩니다. 하나라도 빠지면 그림자가 제대로 나타나지 않으니 순서대로 확인해 보세요.
1. 렌더러(Renderer)에서 그림자 활성화
가장 먼저, 렌더러에게 앞으로 이 장면에서는 그림자를 계산할 것이라고 알려줘야 합니다. 전역 스위치를 켜는 것과 같습니다.
// 렌더러가 그림자 맵을 렌더링하도록 설정합니다.
renderer.shadowMap.enabled = true;
2. 조명(Light)이 그림자를 만들도록 설정
모든 조명이 그림자를 샐성할 수 있는 것은 아닙니다. DirectionalLight, PointLight, SpotLight와 같은 특정 조명만 그림자를 드리울 수 있습니다.(AmbientLight나 HemisphereLight는 그림자를 만들지 못합니다.)
조명을 설정할 때 castShadow 속성을 true로 설정해야 합니다.
// DirectionalLight가 그림자를 생성하도록 활성화합니다.
directionalLight.castShadow = true;
3단계: 그림자를 만드는 객체(Object) 설정
장면 안에 모든 객체가 그림자를 만들 필요는 없습니다. 성능을 위해 그림자를 생성할 객체를 명시적으로 지정해야 합니다.
// 이 구(sphere)는 그림자를 만듭니다.
sphere.castShadow = true;
4단계: 그림자를 그려질 객체(Object) 설정
마지막으로, 다른 객체에 의해 생긴 그림자가 그려질 바닥이나 벽 같은 객체를 지정해야 합니다.
// 이 평면(plane)은 다른 객체의 그림자를 받습니다.
plane.receiveShadow = true;
위 4가지 설정이 모두 완료되면, 장면에서 그림자를 볼 수 있게 됩니다.
품질 & 성능 최적화 팁
기본 설정을 마치면 그림자가 보이지만, 계단 현상(aliasing)이 보이거나 성능이 저하될 수 있습니다. 다음 속성들을 조절하여 그림자의 품질과 성능 사이의 균형을 맞출 수 있습니다.
그림자는 '섀도우 맵'이라는 텍스처를 통해 만들어집니다. 이 이미지의 해상도가 곧 그림자의 선명도를 결정하죠.
// 섀도우 맵의 해상도를 높여 그림자를 더 선명하게 만듭니다.
// 기본값은 512x512이며, 2의 거듭제곱(1024, 2048...)을 사용하는 것이 좋습니다.
directionalLight.shadow.mapSize.width = 2048;
directionalLight.shadow.mapSize.height = 2048;
mapSize가 커질수록 더 많은 메모리와 연산이 필요합니다. 성능 저하가 발생할 수 있으니, 품질과 성능 사이의 적절한 균형점을 찾아야 합니다.
그림자 카메라(Shadow Camera) 범위 최적화
DirectionalLight는 그림자를 만들기 위해 내부적으로 별도의 카메라(OrthographicCamera)를 사용합니다. 이 카메라가 비추는 영역이 바로 그림자가 렌더링 될 범위(절두체, Frustum)입니다.
핵심은 이 범위를 그림자가 필요한 영역에 딱 맞게 좁혀주는 것입니다. 한정된 해상도의 텍스처를 더 좁은 영역에 집중시키면, 단위 면적 당 픽셀 수가 늘어나 그림자가 훨씬 더 선명해집니다.
// CameraHelper를 사용하면 그림자 카메라의 범위를 시각적으로 확인할 수 있습니다.
const directionalLightCameraHelper = new THREE.CameraHelper(directionalLight.shadow.camera);
scene.add(directionalLightCameraHelper);
// 카메라가 비추는 영역의 상하좌우 및 앞뒤(near/far) 범위를 조절합니다.
directionalLight.shadow.camera.top = 2;
directionalLight.shadow.camera.right = 2;
directionalLight.shadow.camera.bottom = -2;
directionalLight.shadow.camera.left = -2;
directionalLight.shadow.camera.near = 1;
directionalLight.shadow.camera.far = 10;
// 범위를 수정한 후에는 반드시 업데이트를 호출해야 합니다.
directionalLight.shadow.camera.updateProjectionMatrix();
최적화가 끝나면 헬퍼는 보이지 않게 처리하는 것을 잊지 마세요.
directionalLightCameraHelper.visible = false
다른 Light도 헬퍼가 존재하기 때문에, 작업할 때 헬퍼를 이용해서 작업해주세요.
그림자를 가장자리 부드럽게 만들기
날카로운 그림자 가장자리를 부드럽게 만들고 싶다면, 렌더러의 shadowMap.type을 변경할 수 있습니다.
// 기본값인 PCFShadowMap보다 더 부드러운 PCFSoftShadowMap을 사용합니다.
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
- THREE.BasicShadowMap: 성능 최고, 품질 최저
- THREE.PCFShadowMap: 괜찮은 성능과 약간 부드러운 가장자리(기본값)
- THREE.PCFSoftShadowMap: 더 부드러운 가장자리, 약간의 성능 저하
- THREE.VSMShadowMap: 매우 부드럽지만 사용이 까다로울 수 있음
최적화: 가짜 그림자(Fake Shadow) 활용하기
실시간 그림자는 매우 비싼 연산입니다. 이때 '진짜' 같은 '가짜' 그림자를 활용하는 것이 최적화 비결입니다.
Baked Shadow
'그림자를 굽는다(Baking)'는 것은, 3D 툴에서 미리 고품질의 그림자를 렌더링하여 이미지 텍스처로 저장하는 기법을 말합니다. 마치 그림자를 바닥에 페인트로 칠해버리는 것과 같죠.
조명과 객체가 움직이지 않는 정적인(Static) 장면에 사용됩니다. 구현 방법은 다음과 같습니다:
- 실시간 그림자 기능을 끕니다. renderer.shadowMap.enabled = false;
- 미리 만들어 둔 그림자 텍스처를 로드합니다.
- 바닥 평면에 MeshBasicMaterial과 함께 텍스처를 적용합니다.
const bakedShadow = new THREE.TextureLoader().load('/textures/bakedShadow.jpg');
const plane = new THREE.Mesh(
new THREE.PlaneGeometry(5, 5),
new THREE.MeshBasicMaterial({ map: bakedShadow })
);
Baked Shadow는 성능이 지극히 뛰어나지만, 객체가 움직여도 그림자는 따라 움직이지 않는다는 명확한 한계가 존재합니다.
동적 가짜 그림자(Simple Dymaic Shadow)
실시간 그림자는 비싸지만, 움직이는 객체에는 Baked Shadow를 쓸 수 없습니다. 이 딜레마를 해결하는 것이 바로 '가짜 그림자' 트릭입니다. 성능은 거의 공짜에 가깝지만, 결과는 매우 그럴듯하죠. 이 기법의 원리를 단계별로 깊이 파헤쳐 보겠습니다.
먼저 그림자 역할을 할 평평한 원판을 만듭니다. 이것이 진짜 그림자가 아니라, 그림자인 척 하는 하나의 Mesh 객체입니다.
// 1. 그림자 텍스처 로드
// 가운데는 흰색, 가장자리는 검은색/투명한 블러 처리된 원형 이미지입니다.
const simpleShadowTexture = new THREE.TextureLoader().load('/textures/simpleShadow.png');
// 2. 그림자 역할을 할 Mesh 생성
const sphereShadow = new THREE.Mesh(
new THREE.PlaneGeometry(1.5, 1.5), // 그림자가 될 평면
new THREE.MeshBasicMaterial({
color: 0x000000, // 그림자 색상은 검은색
transparent: true, // 투명도 사용!
alphaMap: simpleShadowTexture // 텍스처의 흰색 부분만 불투명하게 렌더링
})
);
// 3. 그림자 배치
sphereShadow.rotation.x = -Math.PI * 0.5; // 바닥에 눕히기
sphereShadow.position.y = plane.position.y + 0.01; // 바닥 바로 위에 띄워 Z-fighting 방지
scene.add(sphereShadow);
MeshBasicMaterial은 Scene의 조명에 영향을 받지 않습니다. 진짜 빛으로 그림자를 만드는 게 아니므로, 항상 일정한 검은색을 유지하기에는 완벽합니다. 그리고, transparent: true로 투명도를 조절할 수 있도록 선언했습니다.
alphaMap이 핵심인데, alphaMap은 텍스처의 명암(밝기)을 이용해 투명도를 결정합니다. 흰색에 가까울수록 불투명해지고, 검은색에 가까울수록 투명해집니다. 흐릿한 원형 텍스처를 사용해서, 딱딱한 사각형이 아닌 부드러운 원형 그림자를 만드는 것입니다.
가짜 그림자는 완벽한 사실감을 위한 기술이 아니라, 최소한의 성능 비용으로 '그럴듯한' 효과를 내기 위한 타협점입니다. 게임 플레이 시 먼 시점에서는 지형이 충분히 자연스럽지만, 가까이 다가가면 '속임수'의 한계가 드러나는 것입니다.
애니메이션을 이용한 동적 그림자 확인
이제 애니메이션 루프(tick 함수) 안에서 구와 그림자를 움직여 보겠습니다. X, Z값을 이동해 그림자가 성공적으로 이동합니다.
// elapsedTime: 시간이 지남에 따라 계속 증가하는 값
const elapsedTime = clock.getElapsedTime();
// 1. 원형으로 움직이기 (수평)
sphere.position.x = Math.cos(elapsedTime) * 1.5;
sphere.position.z = Math.sin(elapsedTime) * 1.5;
// 2. 통통 튀는 움직임 (수직)
// Math.sin()은 -1과 1 사이를 부드럽게 오가는 파형을 만듭니다.
// Math.abs()는 절대값으로, 음수 값을 양수로 바꿔줍니다.
// 결과적으로 sphere.position.y는 0 -> 1 -> 0 -> 1... 로 부드럽게 튀어 오릅니다.
sphere.position.y = Math.abs(Math.sin(elapsedTime * 3));
이후에 Y값 sin을 통해 통통 튀게 움직이면서 쉐도우가 동적으로 움직이는 것을 확인해보겠습니다.
// 통통 튀는 움직임 (수직)
// Math.sin()은 -1과 1 사이를 부드럽게 오가는 파형을 만듭니다.
// Math.abs()는 절대값으로, 음수 값을 양수로 바꿔줍니다.
// 결과적으로 sphere.position.y는 0 -> 1 -> 0 -> 1... 로 부드럽게 튀어 오릅니다.
sphere.position.y = Math.abs(Math.sin(elapsedTime * 3));
// 그림자는 구의 수평 위치를 그대로 따라갑니다.
sphereShadow.position.x = sphere.position.x;
sphereShadow.position.z = sphere.position.z;
// (핵심!) 그림자의 투명도를 조절합니다.
sphereShadow.material.opacity = (1 - sphere.position.y) * 0.7;
opacity 한 줄의 코드가 어떻게 현실감을 만들어낼까요?
- 현실 세계의 물리: 바닥에서 멀어질수록, 그 그림자는 더 연해지고 흐릿해집니다. 우리는 이 '연해지는' 효과를 투명도(opacity)로 흉내 내는 것입니다.
- sphere.position.y는 공의 높이입니다. 튀는 움직임에 따라 0(바닥)에서 1(최고 높이) 사이의 값을 가집니다.
- (1 - sphere.position.y)는 이 계산을 값을 뒤집는 역할을 합니다.
- 공이 바닥에 있을 때 (y = 0): 1 - 0 = 1. 투명도는 1(완전 불투명)이 됩니다. 그림자가 가장 진하게 되죠.
- 공이 최고 높이에 있을 때 (y = 1): 1 - 1 = 0. 투명도는 0 (완전 투명)이 됩니다. 그림자가 가장 진하죠.
- (* 0.7): 마지막으로 곱해주는 이 값은 그림자의 최대 진하기를 조절합니다. 만약 이 값이 없다면 그림자가 너무 새카맣게 보일 수 있습니다. 0.7을 곱해줌으로써, 그림자가 가장 진할 때도 살짝 투명한 느낌을 주어 더 자연스럽게 만듭니다. 이 값은 원하는 스타일에 맞게 조절할 수 있습니다.
결론
지금까지 Three.js에서 그림자를 다루는 다양한 방법을 살펴보았습니다.
- 실시간 그림자(shadowMap): 가장 사실적이지만 성능 비용이 가장 높습니다. 주인공 캐릭터나 상호작용이 중요한 핵심 객체에 제한적으로 사용하는 것이 좋습니다.
- Baked Shadow: 실시간 그림자 못지않은 품질을 거의 공짜에 가까운 성능으로 구현합니다. 움직이지 않는 모든 배경과 환경에 적극적으로 활용해야 합니다.
- 가짜 동적 그림자: 성능이 매우 중요할 때 움직이는 객체를 위해 사용하는 트릭입니다. 간단한 모바일 게임이나 다수의 객체가 등장하는 Scene에 효과적입니다.
어떤 그림자가 '최고'라고 말할 수는 없습니다. 프로젝트가 추구하는 목표와 구동될 환경을 고려하여, 배운 기술들을 적절히 조합하고 실험 해보세요. 그것이 바로 최적의 그림자를 찾는 가장 빠른 길입니다.
'Three.js > Class techniques' 카테고리의 다른 글
[threejs-journey 2-6] Scroll based animation (1) | 2025.08.16 |
---|---|
[threejs-journey 2-5] Galaxy Generator (0) | 2025.08.13 |
[threejs-journey 2-4] Particles (0) | 2025.08.12 |
[threejs-journey 2-3] Haunted House (0) | 2025.08.10 |
[threejs-journey 2-1] Lights (1) | 2025.07.15 |