의 씬 그래프

※ 이 글은 Three.js의 튜토리얼 시리즈로서, 먼저 Three.js의 기본 구조에 관한 글을 읽고 오길 권장합니다.

Three.js에서 가장 중요한 것은 무엇보다 씬 그래프(scene graph)입니다. 3D 엔진에서 씬 그래프란 요소(node)의 계층 구조를 그림으로 나타낸 것으로, 여기서 각 요소는 각각의 "지역 공간(local space)"을 가리킵니다.

예시가 다소 추상적이니 좀 더 이해하기 쉬운 걸 예로 들어보겠습니다.

태양계, 그 중에서도 태양, 지구, 달이 적당하겠네요.

지구는 태양을 중심으로 공전합니다. 달은 지구를 중심으로 공전하죠. 달의 공전 궤도는 원과 유사합니다. 달의 관점에서 달은 지구의 "지역 공간" 안에서 공전하는 셈이죠. 태양이 봤을 때 달은 취한 사람처럼 스피로그래프(spirograph, 용수철 모양의 그래프)를 그리며 돌지만, 달은 그저 지구의 "지역 공간"을 도는 것에만 집중할 뿐입니다.

좀 더 가까운 예를 들어보죠. 우리는 지구에서 살지만 지구의 자전이나 자전축, 태양을 공전하는 일은 크게 신경쓰지 않습니다. 이건 지구의 일이니까요. 우리가 걷거나, 뭔가를 타고 이동하거나 수영하거나 달리거나 하는 일들은 지구의 일과는 무관해 보입니다. 그래서 옛날 사람들은 지구가 공전, 자전한다는 사실을 쉽게 받아들이지 못했죠. 우리가 걷든, 헤엄을 치든, 우리의 삶은 지구의 "지역 공간" 안에서 이루어집니다. 태양에서 봤을 때 여러분은 지구를 시속 약 1,600km로 돌고 태양의 주위를 시속 약 107,800km로 도는 셈이지만, 우리는 이렇게 빨리 움직이기 위해 따로 노력할 필요가 없습니다. 달과 마찬가지로 우리가 신경써야 하는 건 지구의 "지역 공간" 뿐이죠.

이제 위 예제를 Three.js로 하나씩 구현해볼 겁니다. 먼저 중점에 태양의 역할을 할 구체를 하나 놓는 것으로 시작하죠.

※ 앞으로 설명할 예제는 씬 그래프를 설명하기 위해 태양, 지구, 달을 활용합니다. 실제 태양, 지구, 달의 운행을 구현하려면 물리를 사용해야 하지만, 목적이 씬 그래프이니 씬 그래프로 실제 운행을 모방할 것입니다.

// 회전값을 업데이트할 객체들
const objects = [];

// 하나의 geometry로 모든 태양, 지구, 달을 생성
const radius = 1;
const widthSegments = 6;
const heightSegments = 6;
const sphereGeometry = new THREE.SphereGeometry(
    radius, widthSegments, heightSegments);

const sunMaterial = new THREE.MeshPhongMaterial({emissive: 0xFFFF00});
const sunMesh = new THREE.Mesh(sphereGeometry, sunMaterial);
sunMesh.scale.set(5, 5, 5);  // 태양의 크기를 키움
scene.add(sunMesh);
objects.push(sunMesh);

예제에서는 로우-폴리(low poly) 구체를 사용할 겁니다. 적도를 중심으로 딱 6분할만 한 구체이죠. 이렇게 하면 자전 운동을 쉽게 확인할 수 있습니다.

같은 구체를 재활용할 것이므로 태양의 mesh를 5배로 설정해줍니다.

다음으로 MeshPhongMaterialemissive(방사성) 속성(property)을 노랑으로 지정합니다. 퐁-메터리얼의 emissive 속성은 빛을 반사하지 않는 표면 색상으로, 대신 광원에 해당 색상이 더해집니다.

씬 가운데에 단방향 조명(single point light)도 하나 넣습니다. 조명에 대해서는 나중에 자세히 다루기로 하고, 지금은 한 점에서 발산하는 광원 정도로 알아둡시다.

{
  const color = 0xFFFFFF;
  const intensity = 3;
  const light = new THREE.PointLight(color, intensity);
  scene.add(light);
}

예제를 쉽게 확인하기 위해 카메라를 중점 바로 위에서 아래로 내려다보게 설치합니다. 카메라의 시점을 바꾸는 가장 간단한 방법은 lookAt 메서드를 활용하는 것으로, 이 메서드는 카메라가 넘겨받은 좌표를 바라보게끔 회전시켜줍니다. 하지만 이전에 먼저 카메라에게 어떤 방향이 위인지 알려줘야 합니다. 대부분의 경우 양의 y(positive y) 방향을 위로 설정하면 되지만, 예제의 경우 위에서 아래를 내려다 볼 것이므로 양의 z 방향이 위가 됩니다.

const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
camera.position.set(0, 50, 0);
camera.up.set(0, 0, 1);
camera.lookAt(0, 0, 0);

이전 예제처럼 렌더링 루프에서 objects 배열의 모든 객체를 회전시키겠습니다.

objects.forEach((obj) => {
  obj.rotation.y = time;
});

sunMeshobjects 배열 안에 넣어놨으므로 태양 모델이 회전하는 것을 확인할 수 있습니다.

다음으로 지구를 추가하겠습니다.

const earthMaterial = new THREE.MeshPhongMaterial({color: 0x2233FF, emissive: 0x112244});
const earthMesh = new THREE.Mesh(sphereGeometry, earthMaterial);
earthMesh.position.x = 10;
scene.add(earthMesh);
objects.push(earthMesh);

지구는 푸른색을 사용했으나, 약간의 방사성(emissive) 파랑을 섞어 검은 배경에서 잘 보이도록 만들었습니다.

그리고 이전에 썼던 sphereGeometry와 방금 만든 earthMaterial을 이용해 earthMesh를 만들고, 태양의 10칸 옆에 위치하도록 설정한 뒤 씬에 추가했습니다. 마지막으로 objects 배열에 추가했으므로, 지구도 태양과 마찬가지로 자전하게 됩니다.

하지만 지구가 태양의 주위를 돌진 않습니다. 지구를 바로 씬에 추가하는 대신, 태양의 자식으로 추가하면...

-scene.add(earthMesh);
+sunMesh.add(earthMesh);

...

뭔가 이상합니다. 왜 지구의 크기와 태양의 크기가 같고 또 왜 저렇게 멀리 떨어졌을까요? 기존 카메라로는 지구가 보이지 않아 카메라의 위치도 150칸 위로 옮겼습니다.

방금 우리는 earthMeshsunMesh의 자식으로 추가했습니다. 이전에 sunMesh를 만들 때 sunMesh.scale.set(5, 5, 5)라는 코드로 크기를 5배로 설정했죠. 이는 sunMesh의 "지역 공간" 자체를 5배 키우겠다는 의미입니다. 그래서 지구의 크기도 5배가 되었고, 거리(earthMesh.position.x = 10)도 5배로 적용된 것이죠.

현재 예제의 씬 그래프는 다음과 같습니다.

이를 해결하기 위해 빈 씬 그래프 요소를 하나 추가합니다. 그리고 태양과 지구 둘 다 이 요소의 자식으로 추가할 겁니다.

+const solarSystem = new THREE.Object3D();
+scene.add(solarSystem);
+objects.push(solarSystem);

const sunMaterial = new THREE.MeshPhongMaterial({emissive: 0xFFFF00});
const sunMesh = new THREE.Mesh(sphereGeometry, sunMaterial);
sunMesh.scale.set(5, 5, 5);
-scene.add(sunMesh);
+solarSystem.add(sunMesh);
objects.push(sunMesh);

const earthMaterial = new THREE.MeshPhongMaterial({color: 0x2233FF, emissive: 0x112244});
const earthMesh = new THREE.Mesh(sphereGeometry, earthMaterial);
earthMesh.position.x = 10;
-sunMesh.add(earthMesh);
+solarSystem.add(earthMesh);
objects.push(earthMesh);

여기서는 Object3D를 생성했습니다. Object3DMesh와 마찬가지로 씬 그래프의 한 요소지만, material이나 geometry가 없다는 점이 다릅니다. 그저 하나의 빈 "지역 공간"인 셈이죠.

이제 씬 그래프는 다음과 같습니다.

sunMeshearthMeshsolarSystem의 자식입니다. 이 3 객체는 각각 회전하죠. 이제 earthMeshsunMesh의 자식이 아니므로 5배 커지지도 않았습니다.

훨씬 낫네요. 지구는 태양보다 작고 태양을 공전하는 동시에 자전까지 합니다.

같은 패턴으로 달도 추가해봅시다.

+const earthOrbit = new THREE.Object3D();
+earthOrbit.position.x = 10;
+solarSystem.add(earthOrbit);
+objects.push(earthOrbit);

const earthMaterial = new THREE.MeshPhongMaterial({color: 0x2233FF, emissive: 0x112244});
const earthMesh = new THREE.Mesh(sphereGeometry, earthMaterial);
-solarSystem.add(earthMesh);
+earthOrbit.add(earthMesh);
objects.push(earthMesh);

+const moonOrbit = new THREE.Object3D();
+moonOrbit.position.x = 2;
+earthOrbit.add(moonOrbit);

+const moonMaterial = new THREE.MeshPhongMaterial({color: 0x888888, emissive: 0x222222});
+const moonMesh = new THREE.Mesh(sphereGeometry, moonMaterial);
+moonMesh.scale.set(.5, .5, .5);
+moonOrbit.add(moonMesh);
+objects.push(moonMesh);

이전처럼 Object3D를 이용해 eathOrbit "지역 공간"을 만들고 거기에 earthMeshmoonMesh를 추가했습니다. 씬 그래프는 다음과 같죠.

그리고 결과물입니다.

처음에 봤던 예제처럼 달이 스피로그래프를 그리며 돌지만, 복잡한 수학적 연산이 하나도 들어가지 않았습니다. 우리가 한 건 씬 그래프에게 그 연산을 대신 맡긴 것 뿐이죠.

때론 씬 그래프의 요소를 시각화하는 것이 도움이 될 때도 있습니다. Three.js는 유용한.. 음... 그러니까 이 거시기를 도와줄 헬퍼 클래스가 있습니다.

그 중 하나는 AxesHelper로, 이 클래스는 지역 X, Y, Z 축을 표시해줍니다. 한 번 여태까지 만든 요소에 모두 추가해보죠.

// AxesHelper 클래스를 각 요소에 지정
objects.forEach((node) => {
  const axes = new THREE.AxesHelper();
  axes.material.depthTest = false;
  axes.renderOrder = 1;
  node.add(axes);
});

우리는 축이 구체 내부에 있더라도 전부 보이길 원하므로, 각 축의 depthTestfalse로 설정합니다. 이러면 Three.js는 어떤 물체 뒤에 있는 요소를 그릴지 말지 검사하는 과정을 생략하므로, 어떤 방향에서라도 축을 볼 수 있습니다. 그리고 renderOrder를 1로 설정(기본값은 0)해 구체를 전부 렌더링한 후 축을 렌더링하도록 합니다. 그렇지 않으면 축을 그린 후 구체가 그려져 보이지 않을 수도 있으니까요.

x축(빨강) 그리고 z축(파랑) 축이 보이나요? 카메라가 바로 위에서 아래를 내려다 보고, 각 물체도 y축을 따라 회전하므로 y축(초록)은 보여도 거의 점처럼 보일 겁니다.

몇몇 축은 2개의 축이 겹쳐져 구별이 어려울 수 있습니다. sunMeshsolarSystem, earthMeshearthOrbit이 같은 위치에 있기 때문이죠. 각 노드의 축을 켜고 끌 수 있는 간단한 컨트롤 패널을 한 번 만들어보죠. 동시에 다른 헬퍼 클래스인 GridHelper도 추가해보겠습니다. GridHelper는 X, Z축으로 2D 격자(grid)를 만다는 클래스로, 기본값은 10x10 칸입니다.

또 Three.js와 함께 사용하기로 유명한 lil-gui도 사용할 겁니다. lil-gui는 UI 라이브러리로, 객체와 속성 이름을 넘겨받고, 해당 속성의 타입을 기반으로 속성값을 UI로 조정할 수 있게 해줍니다.

각 요소에 GridHelperAxesHelper를 추가하겠습니다. 각 노드에 헬퍼를 추가하기 위해 각 노드의 이름이 필요하니, 기존 렌더링 루프를 제거하고 특정 함수를 호출하게 변경하겠습니다.

-// add an AxesHelper to each node
-objects.forEach((node) => {
-  const axes = new THREE.AxesHelper();
-  axes.material.depthTest = false;
-  axes.renderOrder = 1;
-  node.add(axes);
-});

+function makeAxisGrid(node, label, units) {
+  const helper = new AxisGridHelper(node, units);
+  gui.add(helper, 'visible').name(label);
+}
+
+makeAxisGrid(solarSystem, 'solarSystem', 25);
+makeAxisGrid(sunMesh, 'sunMesh');
+makeAxisGrid(earthOrbit, 'earthOrbit');
+makeAxisGrid(earthMesh, 'earthMesh');
+makeAxisGrid(moonMesh, 'moonMesh');

makeAxisGrid 함수는 나중에 만들 AxisGridHelper를 생성하여 lil-gui에 붙이는 역할을 합니다. 예제에서는 체크박스를 만들 것이므로, boolean 타입으로 속성을 지정해주겠습니다. 또 하나의 속성이 바뀔 때 축과 격자가 동시에 나타나고 사라지게 할 것이니 getter와 setter가 있는 간단한 클래스를 하나 만들겠습니다. 이러면 lil-gui가 하나의 속성을 바꿀 때 요소의 AxesHelperGridHelper의 속성을 동시에 조작할 수 있죠.

/* 
 * 축과 격자를 동시에 켜고 끕니다
 * lil-gui가 체크박스를 만들게 하려면 boolean 타입의
 * 속성을 지정해줘야 하므로, `visible` 속성에
 * getter와 setter를 지정해 lil-gui가 이 속성을
 * 바라보도록 합니다
 */
class AxisGridHelper {
  constructor(node, units = 10) {
    const axes = new THREE.AxesHelper();
    axes.material.depthTest = false;
    axes.renderOrder = 2;  // 격자 다음에 렌더링
    node.add(axes);

    const grid = new THREE.GridHelper(units, units);
    grid.material.depthTest = false;
    grid.renderOrder = 1;
    node.add(grid);

    this.grid = grid;
    this.axes = axes;
    this.visible = false;
  }
  get visible() {
    return this._visible;
  }
  set visible(v) {
    this._visible = v;
    this.grid.visible = v;
    this.axes.visible = v;
  }
}

격자가 축을 가릴 수 있으니, AxesHelperrenderOrder를 2로 설정하고 GridHelper를 2로 설정해 축을 격자 다음에 렌더링하도록 합니다.

solarSystem을 체크하면 위에서 설정했듯 지구가 정확히 중앙으로부터 10칸 떨어진 것을 확인할 수 있습니다. 지구가 solarSystem "지역 공간" 안에 있는 것도 확인할 수 있죠. earthOrbit을 켜면 달도 마찬가지로 earthOrbit의 "지역 공간"의 중심으로부터 정확히 2칸 떨어진 것을 확인할 수 있을 겁니다.

씬 그래프의 다른 예시로 자동차를 들 수 있습니다.

차체(Car body)를 움직이면 바퀴(wheel)도 같이 움직입니다. 차체가 바퀴와는 별도로 튀게 하려면(서스펜션. 역주) 차체와 바퀴를 하나의 차체의 "프레임" 요소의 자식으로 설정할 수 있죠.

다른 예로 게임 속 인간형 캐릭터를 한 번 봅시다.

인간형 캐릭터의 씬 그래프는 꽤 복잡하네요. 위 씬 그래프는 상당히 축소된 버젼인데도 말이죠. 좀 더 세세하게 만든다면 손가락 하나하나(최소한 28마디)와 발가락 하나하나(또 다른 28마디), 얼굴과 턱, 눈 등등으로 나눠야 합니다.

약간 복잡한 씬 그래프를 만들어 봅시다. 탱크가 좋겠네요. 바퀴 6개와 포탑으로 이루어진 간단한 탱크입니다. 또 탱크의 주위를 돌아다니는 구체를 하나 만들어 탱크가 그 구체를 조준하도록 해보겠습니다.

아래는 예제를 구현하기 위한 씬 그래프입니다. mesh는 녹색으로 칠했고, Object3D는 청색, 광원은 갈색, 카메라는 보라색으로 칠했습니다. 하나의 카메라는 씬 그래프에 포함하지 않았습니다.

모든 요소를 어떻게 설정했는지 코드를 하나씩 살펴보죠.

탱크가 조준할 목표를 만들기 위해 먼저 위 예제의 earthOrbit과 유사한 targetOrbit(Object3D)을 만듭니다. 그리고 targetOrbit의 상대 좌표를 넘겨줄 targetElevation(Object3D)을 만들어 targetOrbit의 자식으로 추가한 뒤, 또 다른 Object3D, targetBob을 만들어 targetElevation의 자식으로 추가합니다. 이 targetBob은 위아래로 보빙(bob은 낙시찌, 권투에서 bobbing은 몸을 숙이는 동작을 말함. 역주)하는 역할을 할 겁니다. 마지막으로 색이 색이 바뀌는 동시에 회전할 targetMesh 육면체를 만듭니다.

// 움직이는 목표
targetOrbit.rotation.y = time * .27;
targetBob.position.y = Math.sin(time * 2) * 4;
targetMesh.rotation.x = time * 7;
targetMesh.rotation.y = time * 13;
targetMaterial.emissive.setHSL(time * 10 % 1, 1, .25);
targetMaterial.color.setHSL(time * 10 % 1, 1, .25);

탱크는 먼저 tank라는 이름으로 다른 요소를 감쌀 Object3D를 하나 생성합니다. 예제에서는 커브에 따라 위치값을 반환받을 수 있는 SplineCurve를 이용하겠습니다. 0.0은 커브의 시작점이고, 1.0은 커브의 끝점으로, 먼저 탱크의 위치를 넘겨주어 탱크의 다음 위치를 정한 뒤(아래 tankPosition. 역주), 커브의 다음 값을 받아 탱크가 어디를 바라봐야할지 구합니다(아래 tankTarget. 역주). 그리고 구한 값을 Object3D.lookAt 메서드에 넘겨주어 탱크가 그 방향을 바라보도록 합니다.

const tankPosition = new THREE.Vector2();
const tankTarget = new THREE.Vector2();

...

// move tank
const tankTime = time * .05;
curve.getPointAt(tankTime % 1, tankPosition);
curve.getPointAt((tankTime + 0.01) % 1, tankTarget);
tank.position.set(tankPosition.x, 0, tankPosition.y);
tank.lookAt(tankTarget.x, 0, tankTarget.y);

그 다음 탱크의 포탑을 탱크의 자식으로 지정해서 탱크를 따라 움직이게 합니다. 그리고 목표물의 전역 위치값(global position)을 구한 뒤 Object3D.lookAt 메서드를 이용, 포탑이 목표물을 조준하게 합니다.

const targetPosition = new THREE.Vector3();

...

// 목표를 조준하도록
targetMesh.getWorldPosition(targetPosition);
turretPivot.lookAt(targetPosition);

turretCameraturretMesh의 자식으로 지정해 포탑과 함께 카메라가 움직이도록 설정합니다. 또 카메라도 목표물을 바라보게 변경합니다.

// 포탑 카메라가 목표물을 바라보도록
turretCamera.lookAt(targetPosition);

targetCameraPivottargetBob의 자식으로 지정해 목표물과 함께 돌아다니도록 하고, 탱크의 뒤쪽을 바라보도록 합니다. 이는 targetCamera가 목표물의 위치에서 살짝 벗어나게 하기 위함으로, 만약 카메라를 targetBob의 자식으로 바로 추가한다면 목표물 안에서 탱크를 보게 될 겁니다.

// targetCameraPivot이 탱크를 바라보도록
tank.getWorldPosition(targetPosition);
targetCameraPivot.lookAt(targetPosition);

다음으로 바퀴를 회전시킵니다.

wheelMeshes.forEach((obj) => {
  obj.rotation.x = time * 3;
});

그리고 카메라를 간단한 설명과 함께 배열로 묶은 뒤,

const cameras = [
  { cam: camera, desc: 'detached camera', },
  { cam: turretCamera, desc: 'on turret looking at target', },
  { cam: targetCamera, desc: 'near target looking at tank', },
  { cam: tankCamera, desc: 'above back of tank', },
];

const infoElem = document.querySelector('#info');

시간에 따라 카메라를 변경하도록 합니다.

const camera = cameras[time * .25 % cameras.length | 0];
infoElem.textContent = camera.desc;

자, 이번 장은 여기까지입니다. 이 글이 씬 그래프가 어떻게 작동하는지, 어떻게 사용해야할지 감을 잡는 데 도움이 되었으면 좋겠네요. Object3D 요소를 만들어 부모로 만드는 것은 Three.js 뿐만 아니라 다른 3D 엔진을 쓸 때도 중요한 요소입니다. 뭔가를 만들다보면 종종 복잡한 수학이 필요한 것처럼 느껴질 수 있는데, 이때 씬 그래프를 사용하지 않는다면 달의 궤도를 계산하거나 자동차 바퀴의 위치를 계산하는 건 굉장히 복잡할 겁니다. 씬 그래프를 적절히 활용하면 이런 복잡한 동작을 더 쉽게 구현할 수 있죠.

다음 장에서는 재질(material)에 대해 알아보겠습니다.