[Three.js] 활용하기 1

Study·2021년 9월 16일
2

Three.js

목록 보기
6/8
post-thumbnail
post-custom-banner

인터넷에서 CC-BY-NC 3.0 풍자 3D 모델을 하나 가져온다.

블렌더 파일을 받아 .OBJ 형식으로 변환한다.

조명 예제와 합쳐 장면에 HemisphereLight 하나, DirectionalLight 하나가 있는 예제가 있는 셈이다. 또 GUI 관련 코드와 정육면체, 구체 관련 코드도 지운다.

다음으로 먼저 OBJLoader 모듈을 로드한다.

import { OBJLoader } from './resources/threejs/r132/examples/jsm/loaders/OBJLoader.js';

OBJLoader 의 인스턴스를 생성한 뒤 .OBJ 파일의 경로와 콜백 함수를 넘겨 load 메소드를 실행한다. 그리고 콜백 함수에서 불러온 모델을 장면에 추가한다.

{
  const objLoader = new OBJLoader();
  objLoader.load('resources/models/windmill/windmill.obj', (root) => {
    scene.add(root);
  });
}

다음과 같은 결과가 나타난다.

재질이 없어 오류가 나는데, .OBJ 파일에도 재질이 없고 따로 재질을 지정하지도 않았기 때문이다.

위에서 생성한 .OBJ 로더에는 이름 : 재질 쌍을 객체로 지정할 수 있다.
.OBJ 파일을 불러올 때, 이름이 지정되었다면 로더에 지정한 재질 중에 이름(키)과 일치하는 재질을 찾아 사용하고, 재질을 찾지 못했다면 기본 재질을 사용한다.

.OBJ 파일을 생성할 때 재질에 대한 데이터를 담은 .MTL 파일이 같이 생성되기도 한다.
방금의 경우도 .MTL 파일이 같이 생성되었다. .MTL 파일은 ASCII 인코딩이므로 일반 텍스트 파일처럼 열어볼 수 있다.

# Blender MTL File: 'windmill_001.blend'
# Material Count: 2
 
newmtl Material
Ns 0.000000
Ka 1.000000 1.000000 1.000000
Kd 0.800000 0.800000 0.800000
Ks 0.000000 0.000000 0.000000
Ke 0.000000 0.000000 0.000000
Ni 1.000000
d 1.000000
illum 1
map_Kd windmill_001_lopatky_COL.jpg
map_Bump windmill_001_lopatky_NOR.jpg
 
newmtl windmill
Ns 0.000000
Ka 1.000000 1.000000 1.000000
Kd 0.800000 0.800000 0.800000
Ks 0.000000 0.000000 0.000000
Ke 0.000000 0.000000 0.000000
Ni 1.000000
d 1.000000
illum 1
map_Kd windmill_001_base_COL.jpg
map_Bump windmill_001_base_NOR.jpg
map_Ns windmill_001_base_SPEC.jpg

텍스처 파일은 보이지 않는데 이는 블렌더 파일에 같이 포함되어 있다.

위와 같이 텍스처 파일을 별도로 내보낼 수 있다.

이러면 블렌더 파일과 같은 경로의 textures 폴더 안에 텍스처 파일이 생성된다.
내보낸 텍스처를 .OBJ 파일과 같은 경로에 둔다.

이제 .MTL 파일을 다시 불러오자.
MTLLoader 모듈을 불러온다.

import * as THREE from './resources/three/r132/build/three.module.js';
import { OrbitControls } from './resources/threejs/r132/examples/jsm/controls/OrbitControls.js';
import { OBJLoader } from './resources/threejs/r132/examples/jsm/loaders/OBJLoader.js';
import { MTLLoader } from './resources/threejs/r132/examples/jsm/loaders/MTLLoader.js';

우선 .MTL 파일을 불러와 MtlObjBridge 로 재질을 만든다.
그리고 OBJLoader 인스턴스에 방금 만든 재질을 추가하여 .OBJ 파일을 불러온다.

모델을 이리저리 둘러보면 풍차의 날개 뒷면이 없는 것을 볼 수 있다.

풍차 뒷면도 렌더링되지 않은 것인데, .MTL 파일을 수정하는 것은 쉽지 않다.
하지만 3가지 정도의 방법이 있다.

  1. 모든 재질을 불러와 반복문으로 처리
 const mtlLoader = new MTLLoader();
 mtlLoader.load('resources/models/windmill/windmill.mtl', (mtl) => {
   mtl.preload();
   for (const material of Object.values(mtl.materials)) {
     material.side = THREE.DoubleSide;
   }
   ...

문제가 해결되지만, 양면 렌더링은 단면 렌더링에 비해 성능이 느리다. 양면일 필요가 있는 재질만 양면으로 렌더링하는게 이상적이다.

  1. 특정 재질을 골라 선정

.MTL 파일에는 windmll , Material 2개의 재질이 있다. 여러 번의 시도와 에러 끝에 날개가 Material 이란 이름의 재질을 쓴다는 것을 알아낸 뒤, 이 재질만 양면 속성을 설정할 수 있다.

 const mtlLoader = new MTLLoader();
 mtlLoader.load('resources/models/windmill/windmill.mtl', (mtl) => {
   mtl.preload();
   mtl.materials.Material.side = THREE.DoubleSide;
   ...
  1. .MTL 파일의 한계에 굴복하고 직접 재질을 만듦
 objLoader.load('resources/models/windmill/windmill.obj', (root) => {
   const materials = {
     Material: new THREE.MeshPhongMaterial({...}),
     windmill: new THREE.MeshPhongMaterial({...}),
   };
   root.traverse(node => {
     const material = materials[node.material?.name];
     if (material) {
       node.material = material;
     }
   })
   scene.add(root);
 });

1번은 간단하며 3번은 확장성이 좋다. 2번은 중간이며 2번을 이용하여 해결해보자.

해결책을 적용하면 날개가 제대로 보이지만 모델을 확대하면 텍스처가 굉장히 각쳐보일 것이다.

텍스처 파일에는 NOR, 법선 맵이란 이름의 파일이 있는데 이 파일이 바로 법선 맵이다.

범프 맵이 흑백이라면 범선 맵은 보통 자주색을 띈다. 범프 맵이 표면의 높이를 나타낸다면 법선 맵은 표면의 방향을 나타낸다.

법선의 맵의 키가 norm 이어야 하는데, 간단히 .MTL 파일을 수정해보자.

# Blender MTL File: 'windmill_001.blend'
# Material Count: 2
 
newmtl Material
Ns 0.000000
Ka 1.000000 1.000000 1.000000
Kd 0.800000 0.800000 0.800000
Ks 0.000000 0.000000 0.000000
Ke 0.000000 0.000000 0.000000
Ni 1.000000
d 1.000000
illum 1
map_Kd windmill_001_lopatky_COL.jpg
# map_Bump windmill_001_lopatky_NOR.jpg
norm windmill_001_lopatky_NOR.jpg
 
newmtl windmill
Ns 0.000000
Ka 1.000000 1.000000 1.000000
Kd 0.800000 0.800000 0.800000
Ks 0.000000 0.000000 0.000000
Ke 0.000000 0.000000 0.000000
Ni 1.000000
d 1.000000
illum 1
map_Kd windmill_001_base_COL.jpg
# map_Bump windmill_001_base_NOR.jpg
norm windmill_001_base_NOR.jpg
map_Ns windmill_001_base_SPEC.jpg

이제 법선 맵이 정상적이며 날개의 뒷면도 제대로 보인다.

다른 파일도 불러와본다.
인터넷을 뒤져서 다른 CC-BY-NC 3.0 풍자 3D 모델을 가져온다.

.OBJ 형식으로 다운받아 해당 형식을 불러온다. (MTL 로더는 제거한다.)

//objLoader.load('resources/models/windmill/windmill.obj', ...
  objLoader.load('resources/models/windmill-2/windmill.obj', ...

근데 아무것도 나타나지 않는다. 크기 때문인지 카메라를 업데이트 해보자.
먼저 모델을 감싸는 육면체를 계산하여 모델의 크기와 중심점을 구하는 코드를 작성한다.

objLoader.load('resources/models/windmill_2/windmill.obj', (root) => {
  scene.add(root);
 
  const box = new THREE.Box3().setFromObject(root);
  const boxSize = box.getSize(new THREE.Vector3()).length();
  const boxCenter = box.getCenter(new THREE.Vector3());
  console.log(boxSize);
  console.log(boxCenter);
  ...

콘솔을 확인하면 다음 결과가 나타날 것이다.

size 2123.6499788469982
center p { x: -0.00006103515625, y: 770.0909731090069, z: -3.313507080078125 }

이 카메라는 현재 near 0.1, far 가 100 이므로 약 100칸 정도를 투사한다. 땅도 40x40 칸인데 이 모델은 2000칸이다.
카메라의 시야보다 훨씬 크니 절두체 영역 밖에 있는 것이 당연하다.

수작업으로 고칠 수도 있지만, 카메라가 장면의 크기를 자동으로 감지하도록 만들어본다. 방금 모델의 크기를 구할 때 썼던 육면체를 이용할 수 있다. 카메라의 위치를 정하는데 정해진 방법은 없으며 경우에 따라 카메라의 방향과 위치가 다르니 그때 그때 상황에 맞게 방법을 찾으면 된다.

카메라를 만들어보자. 다음 그림과 같이 만들어본다.

그림과 같이 왼쪽에 카메라, 오른쪽에 풍차를 투사하여 만든다.
방금 풍차를 둘러싼 육면체의 위치값을 계산했으니 이제 얼마나 카메라를 멀리 보내야 육면체가 투사체에 들어올지 계산한다.

절두체의 시야각과 육면체의 크기를 구했으니 기본 삼각함수로 육면체 거리를 구한다.

그림을 기반으로 계산식을 짜본다.

distance = halfSizeToFitOnScreen / tangent(halfFovY) // 거리 = 화면 크기의 반 / 탄젠트(시야각의 절반)

이제 위 계산식으로 코드를 작성하여 카메라가 육면체를 바라보게 설정한다.

function frameArea(sizeToFitOnScreen, boxSize, boxCenter, camera) {
  const halfSizeToFitOnScreen = sizeToFitOnScreen * 0.5;
  const halfFovY = THREE.MathUtils.degToRad(camera.fov * .5);
  const distance = halfSizeToFitOnScreen / Math.tan(halfFovY);
 
  // 육면체의 중심에서 카메라가 있는 곳으로 향하는 방향 벡터를 계산합니다
  const direction = (new THREE.Vector3()).subVectors(camera.position, boxCenter).normalize();
 
  // 방향 벡터에 따라 카메라를 육면체로부터 일정 거리에 위치시킵니다
  camera.position.copy(direction.multiplyScalar(distance).add(boxCenter));
 
  // 육면체를 투사할 절두체를 near와 far값으로 정의합니다
  camera.near = boxSize / 100;
  camera.far = boxSize * 100;
 
  camera.updateProjectionMatrix();
 
  // 카메라가 육면체의 중심을 바라보게 합니다
  camera.lookAt(boxCenter.x, boxCenter.y, boxCenter.z);
}

이 함수는 boxSizesizeToFitOnScreen, 두 개의 크기값을 매개변수로 받는다. boxSize 값으로 sizeToFitOnScreen 값을 대체할 수도 있지만, 이러면 육면체가 화면에 꽉 차게 된다. 조금 여유있는 편이 보기 편하므로 큰 값을 넘겨준다.

{
  const objLoader = new OBJLoader();
  objLoader.load('resources/models/windmill_2/windmill.obj', (root) => {
    scene.add(root);
    // 모든 요소를 포함하는 육면체를 계산합니다
    const box = new THREE.Box3().setFromObject(root);
 
    const boxSize = box.getSize(new THREE.Vector3()).length();
    const boxCenter = box.getCenter(new THREE.Vector3());
 
    // 카메라가 육면체를 완전히 감싸도록 설정합니다
    frameArea(boxSize * 1.2, boxSize, boxCenter, camera);
 
    // 마우스 휠 이벤트가 큰 크기에 대응하도록 업데이트합니다
    controls.maxDistance = boxSize * 10;
    controls.target.copy(boxCenter);
    controls.update();
  });
}

위 예제에서는 boxSize * 1.2 값을 넘겨주어 20% 정도 빈 공간을 더 만들었다. 또 카메라가 장면의 중심을 기준으로 회전하도록 OrbitControls 도 업데이트했다.

이제 코드를 실행하면 다음과 같다.

이제 풍차를 드래그해보자. 처음에 카메라가 풍차의 정면이 아닌 아래쪽을 먼저 보여준다. 이는 풍차가 너무 커서 육면체의 중심이 약 (0, 770, 0)인데, 카메라를 육면체의 중심에서 기존 위치 (0, 10, 20) 방향으로 distance 만큼 옮겼기에 풍차의 아래쪽에 카메라가 위치하게 된 것이다.

카메라의 기존 위치에 상관없이 육면체의 중심을 기준으로 카메라를 배치해본다. 단순히 카메라와 육면체 간 벡터의 y 요소를 0 으로 만들면 된다. y 요소를 0으로 만든 뒤 벡터를 정규화(normalize) 하면, XZ 면에 평행한 벡터 즉, 바닥에 평행한 벡터가 된다.

// 육면체의 중심에서 카메라가 있는 곳으로 향하는 방향 벡터를 계산합니다
//const direction = (new THREE.Vector3()).subVectors(camera.position, boxCenter).normalize();
// 카메라와 육면체 사이의 방향 벡터를 항상 XZ 면에 평행하게 만듭니다
const direction = (new THREE.Vector3())
    .subVectors(camera.position, boxCenter)
    .multiply(new THREE.Vector3(1, 0, 1))
    .normalize();

풍차의 아랫면을 보면 작은 정사각형이 하나 보인다. 원래 땅으로 썼던 평면이다.

원래 땅은 40x40 칸이었으니 풍차에 비해 훨씬 작은 것이 당연하다. 풍차의 크기는 2000칸이 넘는다.
땅을 풍차에 맞게 키워야 한다. 또 크기만 키우면 체크무늬가 너무 작아 확대하지 않는 한 보기가 어려울 테니 체크무늬 한 칸의 크기도 키운다.

// const planeSize = 40;
const planeSize = 4000;
 
const loader = new THREE.TextureLoader();
const texture = loader.load('resources/images/checker.png');
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.magFilter = THREE.NearestFilter;
// const repeats = planeSize / 2;
const repeats = planeSize / 200;
texture.repeat.set(repeats, repeats);

이제 재질을 다시 붙여보자. 이전 모델과 마찬가지로 텍스처에 대한 데이터를 담은 .MTL 파일이 보인다. 하지만 동시에 다른 문제도 보인다.

 $ ls -l windmill
 -rw-r--r--@ 1 gregg  staff       299 May 20  2009 windmill.mtl
 -rw-r--r--@ 1 gregg  staff    142989 May 20  2009 windmill.obj
 -rw-r--r--@ 1 gregg  staff  12582956 Apr 19  2009 windmill_diffuse.tga
 -rw-r--r--@ 1 gregg  staff  12582956 Apr 20  2009 windmill_normal.tga
 -rw-r--r--@ 1 gregg  staff  12582956 Apr 19  2009 windmill_spec.tga

TARGA(.tga) 라는 큰 파일이 있다.

Three.js 에 TGA 로더가 있기는 하지만 대부분의 경우 이를 사용하는 것은 좋지 않다. 아주 소수의 경우, 예로 사용자가 임의의 3D 모델 파일을 불러와 확인할 수 있는 뷰어를 만든다거나 하는 경우라면 TGA 파일을 사용할 수도 있다.

TGA 파일의 문제점 중 하나는 압축을 거의 하지 않는 점이다. 파일의 크기가 모두 같을 확률은 매우 희박하니, 위 파일들은 아예 압축되지 않았다고 볼 수 있다. 게다가 파일 하나당 무려 12MB이다. 저 파일을 그대로 하용하면 풍차 하나를 보기위해 36MB의 데이터를 다운받아야 한다.

또한 브라우저가 TGA를 지원하지 않아 .JPG.PNG 파일보다 로딩 시간이 훨씬 느릴 것이다.
이 경우에 .JPG 파일로 변환하는 게 가장 좋은 방법이다. TGA 파일은 알파값이 없는 RGB 3개의 채널로 구성된다. JPG도 채널 3개만 사용하니 딱 적당하다. 또 JPG는 손실 압축을 사용하기에 파일 용량을 훨씬 많이 줄일 수 있다.

파일을 열면 각각 해상도가 2048x2048이다. 쓰기에 따라 다르지만 이게 다소 낭비일 수도 있으니 해상도를 1024x1024로 낮추고 포토샵의 퀄리티 설정을 50%로 지정했다. 다시 살펴보자.

 $ ls -l ../threejsfundamentals.org/threejs/resources/models/windmill
 -rw-r--r--@ 1 gregg  staff     299 May 20  2009 windmill.mtl
 -rw-r--r--@ 1 gregg  staff  142989 May 20  2009 windmill.obj
 -rw-r--r--@ 1 gregg  staff  259927 Nov  7 18:37 windmill_diffuse.jpg
 -rw-r--r--@ 1 gregg  staff   98013 Nov  7 18:38 windmill_normal.jpg
 -rw-r--r--@ 1 gregg  staff  191864 Nov  7 18:39 windmill_spec.jpg

36MG에서 0.55MB로 줄였다.

이제 .MTL 파일을 열어 .TGA 파일 경로를 .JPG 파일로 바꾸어 보자.

newmtl blinn1SG
Ka 0.10 0.10 0.10
 
Kd 0.00 0.00 0.00
Ks 0.00 0.00 0.00
Ke 0.00 0.00 0.00
Ns 0.060000
Ni 1.500000
d 1.000000
Tr 0.000000
Tf 1.000000 1.000000 1.000000 
illum 2
//map_Kd windmill_diffuse.tga
map_Kd windmill_diffuse.jpg
 
//map_Ks windmill_spec.tga
map_Ks windmill_spec.jpg
 
//map_bump windmill_normal.tga 
//bump windmill_normal.tga 
map_bump windmill_normal.jpg 
bump windmill_normal.jpg

텍스처의 용량을 최적화했으니 이제 불러와보자.
재질을 불러와 OBJLoader 에 지정한다.

{
  const mtlLoader = new MTLLoader();
  mtlLoader.load('resources/models/windmill_2/windmill-fixed.mtl', (mtl) => {
    mtl.preload();
    const objLoader = new OBJLoader();
    objLoader.setMaterials(mtl);
    objLoader.load('resources/models/windmill/windmill.obj', (root) => {
      root.updateMatrixWorld();
      scene.add(root);
      // 모든 요소를 포함하는 육면체를 계산합니다
      const box = new THREE.Box3().setFromObject(root);
 
      const boxSize = box.getSize(new THREE.Vector3()).length();
      const boxCenter = box.getCenter(new THREE.Vector3());
 
      // 카메라가 육면체를 완전히 감싸도록 설정합니다
      frameArea(boxSize * 1.2, boxSize, boxCenter, camera);
 
      // 마우스 휠 이벤트가 큰 크기에 대응하도록 업데이트합니다
      controls.maxDistance = boxSize * 10;
      controls.target.copy(boxCenter);
      controls.update();
    });
  });
}

결과를 확인했는데 문제가 있다.

  1. 3개의 MTLLoader 가 각각 디퓨즈 색과 디퓨즈 텍스처 맵으로 혼합하는 재질을 만듬.

이는 유용한 기능이지만 .MTL 파일의 디퓨즈 색상은 0이다.

kd 0.00 0.00 0.00

(텍스처 맵 * 0 = 검정)이다. 프로그램에선 디퓨즈 텍스처 맵과 디퓨즈 색을 혼합하지 않아도 모델이 제대로 보인다.

.MTL 파일을 다음과 같이 수정해 문제를 해결할 수 있다.

kd 1.00 1.00 1.00

(텍스처 맵 * 1 = 텍스처 맵) 이다.

  1. 스페큘러(specular) 색이 검정임.

Ks 로 시작하는 줄은 스페큘러 색을 나타낸다. Three.js 는 스페큘러 색을 얼마나 많이 반사할지 결정할 때 스페큘러 맵의 빨강(red) 채널만 사용하기는 하나, 3가지 색상 채널을 모두 지정하긴 해야 한다.

디퓨즈 색과 마찬가지로 .MTL 파일을 다음과 같이 수정한다.

// Ks 0.00 0.00 0.00
Ks 1.00 1.00 1.00
  1. windmill_normal.jpg 가 법선 맵이 아닌 범프 맵임.

마찬가지로 .MTL 파일을 수정해 준다.

// map_bump windmill_normal.jpg 
// bump windmill_normal.jpg 
norm windmill_normal.jpg

위 변경 사항을 모두 반영하면 재질이 정상적으로 적용될 것이다.

모델을 불러올 때 주의할 점은 다음과 같다.

  • 크기를 알아야 함

예제는 카메라가 정면 전체를 감싸도록 했지만 최적의 해결책일 수 는 없다. 직접 모델을 만들거나, 모델의 크기를 조절하는 것이 더 이상적이다.

  • 잘못된 방향 축

Three.js 에선 보통 Y 축이 위쪽이다. 모델링 프로그램에선 Z축이 위쪽인 경우, Y축이 위쪽인 경우, 직접 설정할 수 있는 경우 등 경우가 다양하다. 모델을 불러왔지만 방향이 잘못되었다면, 모델을 불러온 후 방향을 바꾸거나(권장하진 않음), 3D 프로그램이나 커맨드 라인 프로그램으로 모델을 원하는 방향으로 맞출 수 있다. 브라우저에서 이미지를 쓸 때와 마찬가지로, 이미지를 수정하는 코드를 넣는 것보다 이미지를 다운받아 이미지 자체를 편집하는 게 더 나을 수 있다.

  • .MTL 파일이 없거나 재질 또는 지원하지 않는 값이 있는 경우

위 예제는 .MTL 파일 덕에 재질을 만드는 수고를 덜었지만 .OBJ 파일에 어느 재질이 있는지 확인하거나 Three.js 로 .OBJ 파일을 불러와 재질을 전부 출력하는 것이 자주 있는 일인데, 그런 후 .MTL 파일 대신 직접 재질을 만들어 적절한 이름/재질 쌍의 객체로 로더에 넘기거나 장면을 렌더링한 뒤 테스트하면서 문제를 수정하는 것이다.

  • 고용량 텍스처

3D 모델은 주로 건축, 영화나 광고, 게임 등에 사용된다.
건축이나 영화 같은 분야는 텍스처 용량을 신경 쓸 필요는 없다. 반면 게임의 경우는 메모리도 제한적이며 로컬 환경에서 구동되기에 신경을 꽤 써야한다. 웹 페이지의 경우는 빠르게 불러와야 하니 용량이 퀄리티가 너무 떨어지지 않는 선에서 최대한 작은게 좋다. 첫 번째로 쓴 풍차의 경우 실제로 텍스처를 손 볼 필요가 있다. 지금은 총 용량이 무려 10MB이다.

또한 텍스처 해상도도 고려해야 한다. 50KB 짜리 4096x4096 JPG 이미지는 불러오는 속도는 빠를지 몰라도 메모리를 많이 차지한다.


풍차를 돌리고 싶지만 .OBJ 파일에는 계층 구조가 없다. 즉, 풍차 자체를 1개의 mesh로 취급한다.

이런 이유로 .OBJ 는 그다지 좋은 파일 형식이라고 보기가 어렵다.
그래도 사용하는 이유는 사용법이 간단하여 건축 디자인과 같이 애니메이션이 필요 없는 장면에 정적인 요소로 좋다.

다음으로 gLTF 장면을 불러오는 법을 알아보자.

.GLTF 파일 불러오기

gLTF 형식은 그래픽 요소를 표현하기 위해 설계된 파일 형식이다. 3D 파일 형식은 크게 3, 4개 형식으로 나뉜다.

  • 3D 에디터 형식

특정 프로그램을 위한 파일 형식이다. .blender , max(3D Studio Max) , .mb , ma(마야) 등이 있다.

  • 교환 형식

여기에 .OBJ , .DAE(Collada) , .FBX 등이 속한다.
3D 에디터끼리 데이터를 교환하기 위해 고안된 형식으로, 보통 3D 에디터 내부에서 사용하는 것보다 더 많은 데이터를 포함한다.

  • 앱 형식

특정 앱이나 데임 등에서 사용하는 파일 형식이다.

  • 전달(transmission) 형식

glTF 첫 전달 형식 파일이다. 굳이 따지자면 VRML이 처음이랄 수 있지만 VRML은 부족한 점이 많다.

glTF는 기존 파일 형식에서 부진한 점을 보완한 형식으로, 크게 다음 면에서 기존 형식보다 뛰어나다.

  1. 전달 시 파일 용량 최적화

정점 등의 큰 데이터를 이진수 형태로 저장하는 것을 의미한다.
glTK 파일을 사용하면 별도의 가공 과정 없이 데이터를 GPU에 바로 로드할 수 있다. 반면 VRML, .OBJ , .DAE 등의 형식은 이런 데이터를 텍스트로 저장하여 파싱 과정이 필요하다. 텍스트 기반의 정점 데이터는 이진수 데이터보다 3배에서 많게 5배 크다.

  1. 렌더링 최적화

앱 형식을 제외한 다른 파일 형식과 가른 점이다. glTK 형식의 데이터는 수정이 아니라, 렌더링에 최적화되어 있다. 일반적으로 렌더링에 필요없는 데이터를 제거하는데, 예를 들어 다각형을 glTF 형식으로 저장하면 삼각형으로 변한다. 적용할 재질 데이터도 전부 지정되어 있는 것이다.


glTF는 특정 목적으로 고안되었기에 대부분의 경우 glTF 파일을 다운받아 사용하는 것은 큰 문제가 없다.

인터넷에서 로우-폴리를 하나 찾는다.

.OBJ 예제를 가져와 .GLTF 를 불러오는 코드로 바꾼다.

아래의 기준 코드를

const mtlLoader = new MTLLoader();
mtlLoader.loadMtl('resources/models/windmill/windmill-fixed.mtl', (mtl) => {
  mtl.preload();
  mtl.materials.Material.side = THREE.DoubleSide;
  objLoader.setMaterials(mtl);
  objLoader.load('resources/models/windmill/windmill.obj', (event) => {
    const root = event.detail.loaderRootNode;
    scene.add(root);
    ...
  });
});

.GLTF 를 불러오는 코드로 바꾼다.

{
  const gltfLoader = new GLTFLoader();
  const url = 'resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf';
  gltfLoader.load(url, (gltf) => {
    const root = gltf.scene;
    scene.add(root);
    ...
  });

자동으로 카메라의 시야를 조정하는 코드는 그대로 둔다.

모듈이 바뀌었으니 import 문도 변경해야 한다.
OBJLoader 를 제거하고 GLTFLoader 를 추가한다.

// import { LoaderSupport } from './resources/threejs/r132/examples/jsm/loaders/LoaderSupport.js';
// import { OBJLoader } from './resources/threejs/r132/examples/jsm/loaders/OBJLoader.js';
// import { MTLLoader } from './resources/threejs/r132/examples/jsm/loaders/MTLLoader.js';
import { GLTFLoader } from './resources/threejs/r132/examples/jsm/loaders/GLTFLoader.js';

이제 실행해본다.

이제 자동차가 도로를 따라 달리도록 해보자. 먼저 차가 별도로 요소인지 확인하고, 다룰 수 있는 방법을 찾아야 한다.

먼저 간단하게 함수를 만들어 씬 그래프를 자바스크립트 콘솔에 띄운다.

function dumpObject(obj, lines = [], isLast = true, prefix = '') {
  const localPrefix = isLast ? '└─' : '├─';
  lines.push(`${prefix}${prefix ? localPrefix : ''}${obj.name || '*no-name*'} [${obj.type}]`);
  const newPrefix = prefix + (isLast ? '  ' : '│ ');
  const lastNdx = obj.children.length - 1;
  obj.children.forEach((child, ndx) => {
    const isLast = ndx === lastNdx;
    dumpObject(child, lines, isLast, newPrefix);
  });
  return lines;
}

씬을 전부 불러온 뒤, 만든 함수를 호출한다.

const gltfLoader = new GLTFLoader();
gltfLoader.load('resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf', (gltf) => {
  const root = gltf.scene;
  scene.add(root);
  console.log(dumpObject(root).join('\n'));

코드를 실행하면 아래와 같은 결과가 나온다.

OSG_Scene [Scene]
  └─RootNode_(gltf_orientation_matrix) [Object3D]
    └─RootNode_(model_correction_matrix) [Object3D]
      └─4d4100bcb1c640e69699a87140df79d7fbx [Object3D]
        └─RootNode [Object3D]...
          ├─Cars [Object3D]
          │ ├─CAR_03_1 [Object3D]
          │ │ └─CAR_03_1_World_ap_0 [Mesh]
          │ ├─CAR_03 [Object3D]
          │ │ └─CAR_03_World_ap_0 [Mesh]
          │ ├─Car_04 [Object3D]
          │ │ └─Car_04_World_ap_0 [Mesh]
          │ ├─CAR_03_2 [Object3D]
          │ │ └─CAR_03_2_World_ap_0 [Mesh]
          │ ├─Car_04_1 [Object3D]
          │ │ └─Car_04_1_World_ap_0 [Mesh]
          │ ├─Car_04_2 [Object3D]
          │ │ └─Car_04_2_World_ap_0 [Mesh]
          │ ├─Car_04_3 [Object3D]
          │ │ └─Car_04_3_World_ap_0 [Mesh]
          │ ├─Car_04_4 [Object3D]
          │ │ └─Car_04_4_World_ap_0 [Mesh]
          │ ├─Car_08_4 [Object3D]
          │ │ └─Car_08_4_World_ap8_0 [Mesh]
          │ ├─Car_08_3 [Object3D]
          │ │ └─Car_08_3_World_ap9_0 [Mesh]
          │ ├─Car_04_1_2 [Object3D]
          │ │ └─Car_04_1_2_World_ap_0 [Mesh]
          │ ├─Car_08_2 [Object3D]
          │ │ └─Car_08_2_World_ap11_0 [Mesh]
          │ ├─CAR_03_1_2 [Object3D]
          │ │ └─CAR_03_1_2_World_ap_0 [Mesh]
          │ ├─CAR_03_2_2 [Object3D]
          │ │ └─CAR_03_2_2_World_ap_0 [Mesh]
          │ ├─Car_04_2_2 [Object3D]
          │ │ └─Car_04_2_2_World_ap_0 [Mesh]
          ...

살펴보면 모든 자동차는 Cars 라는 부모의 자식이다.

          ├─Cars [Object3D]
          │ ├─CAR_03_1 [Object3D]
          │ │ └─CAR_03_1_World_ap_0 [Mesh]
          │ ├─CAR_03 [Object3D]
          │ │ └─CAR_03_World_ap_0 [Mesh]
          │ ├─Car_04 [Object3D]
          │ │ └─Car_04_World_ap_0 [Mesh]

간단한 테스트로 Cars 자식 요소 전부를 Y축을 기준으로 회전시켜 본다.

장면을 불러온 뒤, Cars 요소를 참조해 변수로 저장한다.

let cars;
{
  const gltfLoader = new GLTFLoader();
  gltfLoader.load('resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf', (gltf) => {
    const root = gltf.scene;
    scene.add(root);
    cars = root.getObjectByName('Cars');

그리고 render 함수 안에서 cars 의 자식 요소를 전부 회전시킨다.

function render(time) {
  time *= 0.001;  // convert to seconds
 
  if (resizeRendererToDisplaySize(renderer)) {
    const canvas = renderer.domElement;
    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    camera.updateProjectionMatrix();
  }
 
  if (cars) {
    for (const car of cars.children) {
      car.rotation.y = time;
    }
  }
 
  renderer.render(scene, camera);
 
  requestAnimationFrame(render);
}

기준축이 제각각인 것을 볼 수 있다. 트럭이 전부 이상한 방향으로 돈다.

이처럼 3D 프로젝트를 진행할 때 목적에 따라 객체를 디자인해야 한다.
지금은 편법으로 각 자동차에 별도의 Object3D 를 만들어 자동차를 이 Object3D 의 자식으로 지정할 것이다. 이는 자동차의 기준축도 별도로 설정할 수 있다.

아래 코드는 각 자동차를 새로운 Object3D 의 자식으로 지정하고, 이 Object3D 를 장면에 추가한 뒤, 자동차의 종류별로 기준축을 정렬한다. 그리고 새로 만든 Object3Dcars 배열에 추가한다.

// let cars;
const cars = [];
{
  const gltfLoader = new GLTFLoader();
  gltfLoader.load('resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf', (gltf) => {
    const root = gltf.scene;
    scene.add(root);
 
//  cars = root.getObjectByName('Cars');
    const loadedCars = root.getObjectByName('Cars');
    const fixes = [
      { prefix: 'Car_08', rot: [Math.PI * .5, 0, Math.PI +* .5], },
      { prefix: 'CAR_03', rot: [0, Math.PI, 0], },
      { prefix: 'Car_04', rot: [0, Math.PI, 0], },
    ];
 
    root.updateMatrixWorld();
    for (const car of loadedCars.children.slice()) {
      const fix = fixes.find(fix => car.name.startsWith(fix.prefix));
      const obj = new THREE.Object3D();
      car.getWorldPosition(obj.position);
      car.position.set(0, 0, 0);
      car.rotation.set(...fix.rot);
      obj.add(car);
      scene.add(obj);
      cars.push(obj);
    }
     ...

이제 기준축이 제대로 정렬된다.

이제 자동차를 달리게 만들어보자.
도로 전체를 달리는 경로를 만들어 해당 경로에 놓을 수 있다. 아래는 경로를 반쯤 완성했을 때의 블렌더 화면이다.

블렌더에서 데이터를 추출하여 경로만 골라서 내보낸다.
write nurbs 를 체크하여 .OBJ 파일로 내보낸다.

.OBJ 파일을 열면 각 정점 데이터가 있다. 이를 배열로 바꾸어 사용한다.

const controlPoints = [
  [1.118281, 5.115846, -3.681386],
  [3.948875, 5.115846, -3.641834],
  [3.960072, 5.115846, -0.240352],
  [3.985447, 5.115846, 4.585005],
  [-3.793631, 5.115846, 4.585006],
  [-3.826839, 5.115846, -14.736200],
  [-14.542292, 5.115846, -14.765865],
  [-14.520929, 5.115846, -3.627002],
  [-5.452815, 5.115846, -3.634418],
  [-5.467251, 5.115846, 4.549161],
  [-13.266233, 5.115846, 4.567083],
  [-13.250067, 5.115846, -13.499271],
  [4.081842, 5.115846, -13.435463],
  [4.125436, 5.115846, -5.334928],
  [-14.521364, 5.115846, -5.239871],
  [-14.510466, 5.115846, 5.486727],
  [5.745666, 5.115846, 5.510492],
  [5.787942, 5.115846, -14.728308],
  [-5.423720, 5.115846, -14.761919],
  [-5.373599, 5.115846, -3.704133],
  [1.004861, 5.115846, -3.641834],
];

Three.js 에는 몇 가지 곡선 클래스가 있다. 이 경우 CatmullRomCurve3 가 적당하다. 이런 곡선은 각 정점을 지나는 부드러운 곡선을 만든다는 특징이 있다.

위 정점으로 곡선을 생성하면 다음 그림과 같은 곡선이 생길 것이다.

모서리를 각지게 하여 깔끔하게 한다.
정점을 더 추가하여 원하는 결과를 만들어 정점 짝마다 10% 아래에 하나, 두 정점 사이 90% 지점에 하나를 새로 만들어 CatmullRomCurve3 에 넘긴다.

아래는 곡선을 생성하는 코드이다.

let curve;
let curveObject;
{
  const controlPoints = [
    [1.118281, 5.115846, -3.681386],
    [3.948875, 5.115846, -3.641834],
    [3.960072, 5.115846, -0.240352],
    [3.985447, 5.115846, 4.585005],
    [-3.793631, 5.115846, 4.585006],
    [-3.826839, 5.115846, -14.736200],
    [-14.542292, 5.115846, -14.765865],
    [-14.520929, 5.115846, -3.627002],
    [-5.452815, 5.115846, -3.634418],
    [-5.467251, 5.115846, 4.549161],
    [-13.266233, 5.115846, 4.567083],
    [-13.250067, 5.115846, -13.499271],
    [4.081842, 5.115846, -13.435463],
    [4.125436, 5.115846, -5.334928],
    [-14.521364, 5.115846, -5.239871],
    [-14.510466, 5.115846, 5.486727],
    [5.745666, 5.115846, 5.510492],
    [5.787942, 5.115846, -14.728308],
    [-5.423720, 5.115846, -14.761919],
    [-5.373599, 5.115846, -3.704133],
    [1.004861, 5.115846, -3.641834],
  ];
  const p0 = new THREE.Vector3();
  const p1 = new THREE.Vector3();
  curve = new THREE.CatmullRomCurve3(
    controlPoints.map((p, ndx) => {
      p0.set(...p);
      p1.set(...controlPoints[(ndx + 1) % controlPoints.length]);
      return [
        (new THREE.Vector3()).copy(p0),
        (new THREE.Vector3()).lerpVectors(p0, p1, 0.1),
        (new THREE.Vector3()).lerpVectors(p0, p1, 0.9),
      ];
    }).flat(),
    true,
  );
  {
    const points = curve.getPoints(250);
    const geometry = new THREE.BufferGeometry().setFromPoints(points);
    const material = new THREE.LineBasicMaterial({color: 0xff0000});
    curveObject = new THREE.Line(geometry, material);
    scene.add(curveObject);
  }
}

코드의 첫 블록에서 곡선을 만든다. 두 번째 블럭에서는 곡선에서 250개의 정점을 받은 뒤, 이 정점을 이어 곡선을 시각화한다.

하지만 예제를 실행하면 곡선이 보이지 않는다. 일단 확인을 위해 깊이 테스트(depth test) 옵션을 끄고 마지막에 렌더링하도록 설정한다.

    curveObject = new THREE.Line(geometry, material);
    material.depthTest = false;
    curveObject.renderOrder = 1;

다시 예제를 보면 곡선이 매우 작다는 문제가 있었다.

블렌더로 자동차 부모의 스케일을 건드린다.

실제 3D 앱에선 스케일을 건드는 것은 좋지 않다. 갖은 문제가 꼬일 수도 있기 때문이다.

이 예제의 경우 스케일 뿐만 아니라 자동차들의 회전값과 위치값까지 Cars 요소의 영향을 받는다. 이러면 자동차가 돌아다니게 만들기 어렵다. 물론 예제는 차를 전역 공간 안에 움직여야 하기에 어려움이 있지만, 지역 공간에서만 무언가를 조작하는 경우, 이런 것이 큰 걸림돌이 되진 않는다.

씬 그래프를 출력하기 위해 쓴 코드를 다시 가져와 이번엔 각 요소의 위치값(position), 회전값(rotation), 크기값(scale)까지 출력해본다.

function dumpVec3(v3, precision = 3) {
  return `${v3.x.toFixed(precision)}, ${v3.y.toFixed(precision)}, ${v3.z.toFixed(precision)}`;
}
 
function dumpObject(obj, lines, isLast = true, prefix = '') {
  const localPrefix = isLast ? '└─' : '├─';
  lines.push(`${prefix}${prefix ? localPrefix : ''}${obj.name || '*no-name*'} [${obj.type}]`);
  const dataPrefix = obj.children.length
     ? (isLast ? '  │ ' : '│ │ ')
     : (isLast ? '    ' : '│   ');
  lines.push(`${prefix}${dataPrefix}  pos: ${dumpVec3(obj.position)}`);
  lines.push(`${prefix}${dataPrefix}  rot: ${dumpVec3(obj.rotation)}`);
  lines.push(`${prefix}${dataPrefix}  scl: ${dumpVec3(obj.scale)}`);
  const newPrefix = prefix + (isLast ? '  ' : '│ ');
  const lastNdx = obj.children.length - 1;
  obj.children.forEach((child, ndx) => {
    const isLast = ndx === lastNdx;
    dumpObject(child, lines, isLast, newPrefix);
  });
  return lines;
}

코드를 실행하니 다음 결과가 나온다.

OSG_Scene [Scene]
  │   pos: 0.000, 0.000, 0.000
  │   rot: 0.000, 0.000, 0.000
  │   scl: 1.000, 1.000, 1.000
  └─RootNode_(gltf_orientation_matrix) [Object3D]
    │   pos: 0.000, 0.000, 0.000
    │   rot: -1.571, 0.000, 0.000
    │   scl: 1.000, 1.000, 1.000
    └─RootNode_(model_correction_matrix) [Object3D]
      │   pos: 0.000, 0.000, 0.000
      │   rot: 0.000, 0.000, 0.000
      │   scl: 1.000, 1.000, 1.000
      └─4d4100bcb1c640e69699a87140df79d7fbx [Object3D]
        │   pos: 0.000, 0.000, 0.000
        │   rot: 1.571, 0.000, 0.000
        │   scl: 1.000, 1.000, 1.000
        └─RootNode [Object3D]
          │   pos: 0.000, 0.000, 0.000
          │   rot: 0.000, 0.000, 0.000
          │   scl: 1.000, 1.000, 1.000
          ├─Cars [Object3D]
          │ │   pos: -369.069, -90.704, -920.159
          │ │   rot: 0.000, 0.000, 0.000
          │ │   scl: 1.000, 1.000, 1.000
          │ ├─CAR_03_1 [Object3D]
          │ │ │   pos: 22.131, 14.663, -475.071
          │ │ │   rot: -3.142, 0.732, 3.142
          │ │ │   scl: 1.500, 1.500, 1.500
          │ │ └─CAR_03_1_World_ap_0 [Mesh]
          │ │       pos: 0.000, 0.000, 0.000
          │ │       rot: 0.000, 0.000, 0.000
          │ │       scl: 1.000, 1.000, 1.000

보면 기존 장면의 Cars 에 있던 회전값과 크기값이 자식에게 옮겨갔다. 파일을 열었을 때와 렌더링했을 때의 데이터가 다른 이유는 .GLTF 파일으 만들 때 쓴 프로그램이 무언가를 건드리거나, .blend 파일에서 조금 수정한 버전으로 .GLTF 파일을 만들었기 때문이다.

아래 이 요소들도

OSG_Scene [Scene]
  │   pos: 0.000, 0.000, 0.000
  │   rot: 0.000, 0.000, 0.000
  │   scl: 1.000, 1.000, 1.000
  └─RootNode_(gltf_orientation_matrix) [Object3D]
    │   pos: 0.000, 0.000, 0.000
    │   rot: -1.571, 0.000, 0.000
    │   scl: 1.000, 1.000, 1.000
    └─RootNode_(model_correction_matrix) [Object3D]
      │   pos: 0.000, 0.000, 0.000
      │   rot: 0.000, 0.000, 0.000
      │   scl: 1.000, 1.000, 1.000
      └─4d4100bcb1c640e69699a87140df79d7fbx [Object3D]
        │   pos: 0.000, 0.000, 0.000
        │   rot: 1.571, 0.000, 0.000
        │   scl: 1.000, 1.000, 1.000

불필요한 것들이다.

위치값, 회전값, 크기값도 없는 하나의 root 요소가 있는게 더 이상적이다. 런타임에 루트 요소의 자식을 전부 꺼내 장면 자체의 자식으로 지정하거나 Cars와 루트 요소가 자동차를 찾는데 도움이 될 수 있으나, 이 역시 별도의 위치값, 회전값, 크기값이 없는게 나을 수 있도록 간단히 장면을 자동차의 부모로 지정하는 것도 있다.

가장 최선의 해결책은 아니지만 곡선 자체의 크기를 키우는 것이 가장 빠른 해결책이다.

일단 마지막 해결책을 선택하여 진행해보자.

먼저 곡선의 위치를 옮겨 적당한 위치에 둔 뒤 곡선을 숨긴다.

{
  const points = curve.getPoints(250);
  const geometry = new THREE.BufferGeometry().setFromPoints(points);
  const material = new THREE.LineBasicMaterial({color: 0xff0000});
  curveObject = new THREE.Line(geometry, material);
  curveObject.scale.set(100, 100, 100);
  curveObject.position.y = -621;
  curveObject.visible = false;
  material.depthTest = false;
  curveObject.renderOrder = 1;
  scene.add(curveObject);
}

다음으로 자동차가 곡선을 따라 달리도록 코드를 작성한다. 자동차마다 곡선에 비례하여 0에서 1사이의 위치를 정한 뒤, curveObject 를 이용해 전역 공간에서의 위치값을 구한다. 그리고 곡선의에서 조금 더 낮은 값을 구한 뒤, lookAt 메소드를 이용해 자동차가 이 점을 바라보도록 설정하고, 자동차를 위치값과 방금 구한 점 중간에 둔다.

// 경로를 계산할 때 쓸 Vector3 객체를 생성합니다
const carPosition = new THREE.Vector3();
const carTarget = new THREE.Vector3();
 
function render(time) {
  ...
 /*
  for (const car of cars) {
    car.rotation.y = time;
  }
 */
 
  {
    const pathTime = time * .01;
    const targetOffset = 0.01;
    cars.forEach((car, ndx) => {
      // 0에서 1사이의 값으로, 자동차의 간격을 균일하게 배치합니다
      const u = pathTime + ndx / cars.length;
 
      // 첫 번째 점을 구합니다
      curve.getPointAt(u % 1, carPosition);
      carPosition.applyMatrix4(curveObject.matrixWorld);
 
      // 곡선을 따라 첫 번째 점보다 조금 낮은 두 번째 점을 구합니다
      curve.getPointAt((u + targetOffset) % 1, carTarget);
      carTarget.applyMatrix4(curveObject.matrixWorld);
 
      // (임시로) 자동차를 첫 번째 점에 둡니다
      car.position.copy(carPosition);
      // 자동차가 두 번째 점을 바라보게 합니다
      car.lookAt(carTarget);
 
      // 차를 두 점 중간에 둡니다
      car.position.lerpVectors(carPosition, carTarget, 0.5);
    });
  }

실행시켜보면 자동차의 높이 기준도 제각기다.
각 자동차의 위치값을 조금씩 수정한다.

const loadedCars = root.getObjectByName('Cars');
const fixes = [
/*
  { prefix: 'Car_08', rot: [Math.PI * .5, 0, Math.PI * .5], },
  { prefix: 'CAR_03', rot: [0, Math.PI, 0], },
  { prefix: 'Car_04', rot: [0, Math.PI, 0], },
*/
  { prefix: 'Car_08', y: 0,  rot: [Math.PI * .5, 0, Math.PI * .5], },
  { prefix: 'CAR_03', y: 33, rot: [0, Math.PI, 0], },
  { prefix: 'Car_04', y: 40, rot: [0, Math.PI, 0], },
];
 
root.updateMatrixWorld();
for (const car of loadedCars.children.slice()) {
  const fix = fixes.find(fix => car.name.startsWith(fix.prefix));
  const obj = new THREE.Object3D();
  car.getWorldPosition(obj.position);
//car.position.set(0, 0, 0);
  car.position.set(0, fix.y, 0);
  car.rotation.set(...fix.rot);
  obj.add(car);
  scene.add(obj);
  cars.push(obj);
}

마지막으로 그림자까지 추가해보자.
DirectionalLight 그림자 예제를 가져와 그대로 코드를 붙여 넣는다.

그리고 파일을 불러온 뒤, 모든 요소의 그림자 설정을 켜준다.

{
  const gltfLoader = new GLTFLoader();
  gltfLoader.load('resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf', (gltf) => {
    const root = gltf.scene;
    scene.add(root);
 
    root.traverse((obj) => {
      if (obj.castShadow !== undefined) {
        obj.castShadow = true;
        obj.receiveShadow = true;
      }
    });

하지만 그림자 헬퍼가 나타나지 않는데, renderer 의 그림자 설정을 켜주지 않았다.

renderer.shadowMap.enabled = true;

그리고 DirectionLight 의 그림자용 카메라가 장면 전체를 투사하도록 절두체를 조정한다. 다음과 같다.

{
  const color = 0xFFFFFF;
  const intensity = 1;
  const light = new THREE.DirectionalLight(color, intensity);
  light.castShadow = true;
  light.position.set(-250, 800, -850);
  light.target.position.set(-550, 40, -450);
 
  light.shadow.bias = -0.004;
  light.shadow.mapSize.width = 2048;
  light.shadow.mapSize.height = 2048;
 
  scene.add(light);
  scene.add(light.target);
  const cam = light.shadow.camera;
  cam.near = 1;
  cam.far = 2000;
  cam.left = -1500;
  cam.right = 1500;
  cam.top = 1500;
  cam.bottom = -1500;
...

마지막으로 배경색을 옅은 하늘색으로 설정한다.

const scene = new THREE.Scene();
// scene.background = new THREE.Color('black');
scene.background = new THREE.Color('#DEFEFF');

profile
Study
post-custom-banner

1개의 댓글

comment-user-thumbnail
2022년 10월 7일

감사합니닷 🥺

답글 달기