PBR (물리 기반 렌더링) 재질
Three.js는 3D 오브젝트에 대한 재질 (material) 속성을 제공하는데, 이는 오브젝트가 빛을 어떻게 반사하고 카메라에서 어떻게 렌더링되는지를 결정한다. 재질의 속성은 기본 색상 (base color), 금속성 (metalness), 거칠기 (roughness) 등으로 구성된다. 나아가 텍스처 맵 (texture map) 을 이용해 3D 오브젝트의 표면을 꾸밀 수 있다. 텍스처 맵은 오브젝트 표면의 UV 맵을 기준으로 재질의 특성을 기술하는 2D 이미지 맵이다. 따라서 텍스처 맵은 사실적인 객체를 만드는 데 도움을 준다.
이 글은 태양계 시뮬레이터 시리즈의 일부다.
- Three.js 좌표계 기초
- Three.js PBR (물리 기반 렌더링) 재질 기초
- 사실적인 지구 만들기
- 빛나는 태양 만들기
- 고리가 있는 토성 만들기
- 불규칙한 형태의 포보스 만들기
- 태양을 빛나게 하기
- 은하수 스카이박스 만들기
- 타원 궤도 계산하기
사전 준비
코드 수정
지구의 텍스처 변화에 집중하기 위해 지구의 위치를 고정한다. 아래 줄들을 수정하여 태양이 지구 주위를 공전하는 것처럼 보이게 만든다.
scene.add(light);
function updateSystem(sec) {
moon.position.set(0.4*Math.cos(w_moon*sec), 0, -0.4*Math.sin(w_moon*sec));
earth_equator.position.set(3*Math.cos(w_orbit*sec), 0, -3*Math.sin(w_orbit*sec));
earth.rotateY(w_rotate);
}
다음과 같이 바꾼다.
sun.add(light);
function updateSystem(sec) {
moon.position.set(0.4*Math.cos(w_moon*sec), 0, -0.4*Math.sin(w_moon*sec));
sun.position.set(3*Math.cos(w_orbit*sec), 0, -3*Math.sin(w_orbit*sec));
earth.rotateY(w_rotate);
}
또한 광원을 공전하는 태양에 부착하였다. 이것이 훨씬 직관적인 계층 구조다. 그러면 결과는 다음과 같다:

텍스처 다운로드
이 글에서는 사실적인 지구를 만들기 위해 MeshPhongMaterial을 사용한다. 색상, 법선 (normal), 범프 (bump), 정반사 (specular) 등을 위한 텍스처 맵은 여기나 여기에서 찾을 수 있다. 물론 “earth texture map”이라는 키워드로 검색하여 다른 텍스처 맵을 사용해도 된다. 색상 맵과 법선 맵의 예시는 다음과 같다.


법선 맵의 색상은 표면의 정규화된 법선 벡터를 RGB로 나타낸 것이다. 수식으로 표현하면,
\[R = (n_x + 1) / 2\] \[G = (n_y + 1) / 2\] \[B = (n_z + 1) / 2\]이다. 매끄러운 표면을 가진 텍스처는 법선 벡터가 \(n = (0, 0, 1)\)이므로, 법선 맵은 전반적으로 보라색 #7F7FFF로 표현된다.
텍스처 매핑
다운로드한 텍스처 맵을 사용하기 위해 Three.js는 TextureLoader 클래스를 제공한다. 이는 로컬 파일이나 URL에서 텍스처 맵을 불러오는 데 도움을 준다. TextureLoader를 load(url: String, onLoad: Function, onProgress: Function, onError: Function) 콜백 함수와 함께 정의하면, 텍스처 맵이 오브젝트에 비동기적으로 적용된다. 아래에서는 색상 맵, 법선 맵, 정반사 맵을 지구 오브젝트에 적용한다.
const material_earth = new THREE.MeshPhongMaterial({specular: new THREE.Color(0xFFFFFF), shininess: 3});
const earth = new THREE.Mesh(geometry_sphere, material_earth);
earth.scale.set(0.2, 0.2, 0.2);
const loader = new THREE.TextureLoader();
loader.load('./assets/2k_earth_daymap.jpg', (texture)=>{
material_earth.map = texture;
material_earth.needsUpdate = true;
});
loader.load('./assets/2k_earth_normal_map.tif', (texture)=>{
material_earth.normalMap = texture;
material_earth.normalScale = new THREE.Vector2(2, 2);
material_earth.needsUpdate = true;
});
loader.load('./assets/2k_earth_specular_map.tif', (texture)=>{
material_earth.specularMap = texture;
material_earth.needsUpdate = true;
});
재질을 생성한 후, 그 속성을 갱신할 때는 material_earth.needsUpdate = true를 실행해야 한다는 점에 유의한다. 색상, 정반사, 법선 맵을 조합한 결과는 다음과 같다.

각각의 영향을 확인하기 위해 색상 맵, 정반사 맵, 법선 맵을 하나씩만 적용하면 결과는 다음과 같다:
| 색상 맵 | 정반사 맵 | 법선 맵 |
|---|---|---|
![]() |
![]() |
![]() |
바다의 반사율은 육지보다 높기 때문에 정반사 맵에서 바다 부분이 더 밝은 값을 가진다. 또한 법선 맵에서는 태양 방향에 따라 산의 그림자가 변하는 것을 확인할 수 있다.
그림자
지금까지 지구를 만들었고, 다음으로 달을 만들어 보자.
loader.load('./assets/moonmap2k.jpg', (texture)=>{
material_moon.map = texture;
material_moon.needsUpdate = true;
});

결과에서 지구와 달 모두 낮과 밤이 잘 렌더링되었다. 하지만 지구와 달이 태양과 일직선을 이루어도 일식이 일어나지 않는다. 마찬가지로 월식 또한 일어나지 않는다. 그러므로 Three.js renderer의 그림자 효과를 활성화해 보자.
renderer.shadowMap.enabled = true;
...
light.castShadow = true;
...
earth.castShadow = true;
earth.receiveShadow = true;
...
moon.castShadow = true;
moon.receiveShadow = true;
...

renderer.shadowMap은 그림자 맵의 특성을 설정한다. 장면의 스케일에 따라 이 파라미터를 조정해야 한다. castShadow = true는 그림자를 만드는 오브젝트에 적용한다. 반대로 receiveShadow = true는 그림자 영역을 표현하는 오브젝트에 적용한다. 달과 지구는 서로 상호작용하므로 둘 다 castShadow와 receiveShadow를 true로 설정한다. 지구의 castShadow나 달의 receiveShadow를 false로 설정하면 월식이 나타나지 않는다.
이제 식(蝕)을 관찰할 수 있다. 그림자의 경계가 그다지 자연스러워 보이지는 않지만 일단 넘어가자. 그러면 실제 비율을 위해 달의 크기를 줄이면 어떻게 될까?

이 계단 모양으로 래스터화된 그림자 경계는 광원과 지구 사이의 거리가 멀어질수록 더 나빠진다. 그림자를 더 사실적으로 만들기 위해, 아래와 같이 그림자 맵의 크기와 블러(blur)를 설정할 수 있다.
light.shadow.mapSize = new THREE.Vector2(4096, 4096);
light.shadow.radius = 20;

이렇게 더 먼 행성을 위해 그림자 맵 해상도 값을 조정할 수 있지만, 작은 표면의 일부만 그림자의 영향을 받기 때문에 메모리 낭비가 될 것이다. 또한 그림자의 품질은 광원으로부터의 거리에 따라 달라진다. 그러므로 이제 셰이더를 사용할 차례다.
정리하며
한계
결과적으로 재질과 텍스처는 오브젝트를 거의 사실적으로 만들어 준다. 하지만 지구의 밤 영역에도 텍스쳐를 추가하고, 구름의 그림자, 고해상도의 월식/일식을 표현하려면 셰이더 재질이 필요하며, 이는 다음 글에서 다룬다.
전체 코드
import * as THREE from 'three'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
const canvas = document.createElement("canvas");
document.body.appendChild(canvas);
const renderer = new THREE.WebGLRenderer({canvas: canvas, alpha: true, antialias: true});
renderer.setSize( window.innerWidth, window.innerHeight );
renderer.shadowMap.enabled = true;
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 0, 5);
const geometry_sphere = new THREE.SphereGeometry(1, 30, 30);
const material_sun = new THREE.MeshBasicMaterial({color: 0xffaa00});
const sun = new THREE.Mesh(geometry_sphere, material_sun);
const material_earth = new THREE.MeshPhongMaterial({specular: new THREE.Color(0xFFFFFF), shininess: 3});
const earth = new THREE.Mesh(geometry_sphere, material_earth);
earth.scale.set(0.2, 0.2, 0.2);
earth.castShadow = true;
earth.receiveShadow = true;
const loader = new THREE.TextureLoader();
loader.load('./assets/2k_earth_daymap.jpg', (texture)=>{
material_earth.map = texture;
material_earth.needsUpdate = true;
});
loader.load('./assets/2k_earth_normal_map.tif', (texture)=>{
material_earth.normalMap = texture;
material_earth.normalScale = new THREE.Vector2(2, 2);
material_earth.needsUpdate = true;
});
loader.load('./assets/2k_earth_specular_map.tif', (texture)=>{
material_earth.specularMap = texture;
material_earth.needsUpdate = true;
});
const material_moon = new THREE.MeshLambertMaterial();
const moon = new THREE.Mesh(geometry_sphere, material_moon);
moon.scale.set(0.05, 0.05, 0.05);
moon.castShadow = true;
moon.receiveShadow = true;
loader.load('./assets/moonmap2k.jpg', (texture)=>{
material_moon.map = texture;
material_moon.needsUpdate = true;
});
const light = new THREE.PointLight(0xffffff, 15);
light.position.set(0, 0, 0);
light.castShadow = true;
light.shadow.mapSize = new THREE.Vector2(4096, 4096);
light.shadow.radius = 20;
const earth_orbit = new THREE.Object3D();
const earth_equator = new THREE.Object3D();
const moon_orbit = new THREE.Object3D();
// earth_equator.rotateZ(23.5*Math.PI/180);
const scene = new THREE.Scene();
scene.add(sun);
sun.add(light);
scene.add(earth_orbit);
earth_orbit.add(earth_equator);
earth_equator.add(earth);
earth_equator.add(moon_orbit);
moon_orbit.add(moon);
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
const w_moon = 2;
const w_orbit = 0.5;
const w_rotate = 0.1;
function updateSystem(sec) {
moon.position.set(0.4*Math.cos(w_moon*sec), 0, -0.4*Math.sin(w_moon*sec));
sun.position.set(3*Math.cos(w_orbit*sec), 0, -3*Math.sin(w_orbit*sec));
earth.rotateY(w_rotate);
}
function animate (msec) {
requestAnimationFrame(animate);
updateSystem(msec * 0.001);
controls.update();
renderer.render(scene, camera);
}
animate();


