Browse Source

Korean translations (#84)

* 200515

* 200605

* 200617

* 200618

* 200623

* 200629

* 200630

* 200706

* 200707

* 200708

* 200709

* 200710

* 200710 fix typo

* add korean font

* Delete package-lock.json

* add translation kr/threejs-load-obj.md

* add translation kr/threejs-load-gltf.md

* before merge

* Revert "Delete package-lock.json"

This reverts commit 5f778e3c5f4fab41864cc3c2eb81435ef68365b7.

* Update package-lock.json

* add translation kr/threejs-background.md

* Update package-lock.json

put back the package-lock.json

Co-authored-by: Greggman <[email protected]>
Seemspyo 5 years ago
parent
commit
c212f2ab85

+ 268 - 0
threejs/lessons/kr/threejs-backgrounds.md

@@ -0,0 +1,268 @@
+Title: Three.js 배경과 하늘 상자
+Description: THREE.js에서 배경을 넣는 법을 알아봅니다
+TOC: 배경, 하늘 상자 추가하기
+
+이 시리즈의 예제 대부분은 단색 배경을 사용했습니다.
+
+Three.js에서 단순한 배경을 넣는 건 CSS만큼이나 쉽습니다. [반응형 디자인에 관한
+글](threejs-responsive.html)의 예제에서 2가지만 바꿔주면 되죠.
+
+먼저 CSS로 canvas에 배경을 추가합니다.
+
+```html
+<style>
+body {
+    margin: 0;
+}
+#c {
+    width: 100vw;
+    height: 100vh;
+    display: block;
++    background: url(resources/images/daikanyama.jpg) no-repeat center center;
++    background-size: cover;
+}
+</style>
+```
+
+그리고 `WebGLRenderer`에 `alpha` 옵션을 켜 아무것도 없는 공간은 투명하게
+보이도록 설정합니다.
+
+```js
+function main() {
+  const canvas = document.querySelector('#c');
+-  const renderer = new THREE.WebGLRenderer({canvas});
++  const renderer = new THREE.WebGLRenderer({
++    canvas,
++    alpha: true,
++  });
+```
+
+간단하지 않나요?
+
+{{{example url="../threejs-background-css.html" }}}
+
+배경이 [후처리 효과](threejs-post-processing.html)의 영향을 받게 하려면
+Three.js로 배경을 렌더링해야 합니다.
+
+간단히 장면의 배경에 텍스처를 입혀주기만 하면 되죠.
+
+```js
+const loader = new THREE.TextureLoader();
+const bgTexture = loader.load('resources/images/daikanyama.jpg');
+scene.background = bgTexture; 
+```
+
+{{{example url="../threejs-background-scene-background.html" }}}
+
+배경이 지정되긴 했지만, 화면에 맞춰 늘어났네요.
+
+이미지의 일부만 보이도록 `repeat`과 `offset` 속성을 조정해 문제를 해결해봅시다.
+
+```js
+function render(time) {
+
+   ...
++  /**
++   * 배경 텍스처의 repeat과 offset 속성을 조정해 이미지의 비율이 깨지지
++   * 않도록 합니다.
++   * 이미지를 불러오는 데 시간이 걸릴 수 있으니 감안해야 합니다.
++   **/
++  const canvasAspect = canvas.clientWidth / canvas.clientHeight;
++  const imageAspect = bgTexture.image ? bgTexture.image.width / bgTexture.image.height : 1;
++  const aspect = imageAspect / canvasAspect;
++
++  bgTexture.offset.x = aspect > 1 ? (1 - 1 / aspect) / 2 : 0;
++  bgTexture.repeat.x = aspect > 1 ? 1 / aspect : 1;
++
++  bgTexture.offset.y = aspect > 1 ? 0 : (1 - aspect) / 2;
++  bgTexture.repeat.y = aspect > 1 ? 1 : aspect;
+
+  ...
+
+  renderer.render(scene, camera);
+
+  requestAnimationFrame(render);
+}
+```
+
+이제 Three.js가 배경을 렌더링합니다. 그냥 보기에 CSS와 큰 차이는 없지만,
+[후처리 효과](threejs-post-processing.html)의 영향을 받는다는 점이 다릅니다.
+
+{{{example url="../threejs-background-scene-background-fixed-aspect.html" }}}
+
+물론 3D 장면을 만들 때 단순한 배경을 자주 사용하진 않습니다. 대신 주로 일종의
+*하늘 상자(skybox)*를 사용하죠. 하늘 상자란 말 그대로 하늘을 그려놓은 상자로써,
+상자 안에 카메라를 놓으면 마치 배경에 하늘이 있는 것처럼 보이는 효과를 줍니다.
+
+일반적으로 육면체에 텍스처를 입히고 안쪽을 렌더링하도록 설정해 하늘 상자를
+구현합니다. 각 면에 수평선처럼 보이는 이미지를 텍스처로 배치하는 거죠(텍스처
+좌표를 이용해). 하늘 구체(sky sphere)나 하늘 돔(sky dom)도 자주 사용하는
+방식입니다. 다시 말해 육면체나 구체를 만들고, [텍스처를 입힌](threejs-textures.html)
+뒤, 바깥 면이 아닌 안쪽 면을 렌더링하도록 `THREE.BackSide` 값을 넣어주면
+됩니다. 그리고 바로 장면(scene)에 추가하거나, 하늘 상자/구체/돔을 담당할
+장면 하나, 다른 요소를 담당할 장면 하나 이렇게 총 2개를 만들 수도 있죠.
+`OrthographicCamera`를 쓸 필요는 없으니 `PerspectiveCamera`를 그대로
+사용하면 됩니다.
+
+다른 방법 중 하나는 *큐브맵(Cubemap)*입니다. 큐브맵은 정육면체의 한 면 당
+하나, 총 6개의 면을 가진 텍스처로, 텍스처 좌표 대신 중앙에서 바깥쪽을 가리키는
+방향으로 색상값을 결정합니다.
+
+아래 6개의 이미지는 캘리포니아 마운틴 뷰에 있는 컴퓨터 역사 박물관에서 찍은
+사진입니다.
+
+<div class="threejs_center">
+  <img src="../resources/images/cubemaps/computer-history-museum/pos-x.jpg" style="width: 200px" class="border">
+  <img src="../resources/images/cubemaps/computer-history-museum/neg-x.jpg" style="width: 200px" class="border">
+  <img src="../resources/images/cubemaps/computer-history-museum/pos-y.jpg" style="width: 200px" class="border">
+</div>
+<div class="threejs_center">
+  <img src="../resources/images/cubemaps/computer-history-museum/neg-y.jpg" style="width: 200px" class="border">
+  <img src="../resources/images/cubemaps/computer-history-museum/pos-z.jpg" style="width: 200px" class="border">
+  <img src="../resources/images/cubemaps/computer-history-museum/neg-z.jpg" style="width: 200px" class="border">
+</div>
+
+이들을 `CubeTextureLoader`로 불러와 장면의 배경으로 설정합니다.
+
+```js
+{
+  const loader = new THREE.CubeTextureLoader();
+  const texture = loader.load([
+    'resources/images/cubemaps/computer-history-museum/pos-x.jpg',
+    'resources/images/cubemaps/computer-history-museum/neg-x.jpg',
+    'resources/images/cubemaps/computer-history-museum/pos-y.jpg',
+    'resources/images/cubemaps/computer-history-museum/neg-y.jpg',
+    'resources/images/cubemaps/computer-history-museum/pos-z.jpg',
+    'resources/images/cubemaps/computer-history-museum/neg-z.jpg',
+  ]);
+  scene.background = texture;
+}
+```
+
+이 텍스처는 별도 조정이 필요 없으니 위에서 작성했던 코드를 삭제합니다.
+
+```js
+function render(time) {
+
+   ...
+
+-  /**
+-   * 배경 텍스처의 repeat과 offset 속성을 조정해 이미지의 비율이 깨지지
+-   * 않도록 합니다.
+-   * 이미지를 불러오는 데 시간이 걸릴 수 있으니 감안해야 합니다.
+-   **/
+-  const canvasAspect = canvas.clientWidth / canvas.clientHeight;
+-  const imageAspect = bgTexture.image ? bgTexture.image.width / bgTexture.image.height : 1;
+-  const aspect = imageAspect / canvasAspect;
+-
+-  bgTexture.offset.x = aspect > 1 ? (1 - 1 / aspect) / 2 : 0;
+-  bgTexture.repeat.x = aspect > 1 ? 1 / aspect : 1;
+-
+-  bgTexture.offset.y = aspect > 1 ? 0 : (1 - aspect) / 2;
+-  bgTexture.repeat.y = aspect > 1 ? 1 : aspect;
+
+  ...
+
+  renderer.render(scene, camera);
+
+  requestAnimationFrame(render);
+}
+```
+
+카메라도 조작이 가능하도록 만듭니다.
+
+```js
+import { OrbitControls } from './resources/threejs/r115/examples/jsm/controls/OrbitControls.js';
+```
+
+```js
+const fov = 75;
+const aspect = 2;  // canvas 기본값
+const near = 0.1;
+-const far = 5;
++const far = 100;
+const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
+-camera.position.z = 2;
++camera.position.z = 3;
+
++const controls = new OrbitControls(camera, canvas);
++controls.target.set(0, 0, 0);
++controls.update();
+```
+
+예제를 드래그하면 큐브맵이 주위를 둘러싼 게 보일 겁니다.
+
+{{{example url="../threejs-background-cubemap.html" }}}
+
+다른 방법은 등장방형도법(Equirectangular map)을 이용하는 겁니다. 이런 사진은
+주로 [360도 카메라](https://google.com/search?q=360+camera)로 촬영합니다.
+
+[다음 사진](https://hdrihaven.com/hdri/?h=tears_of_steel_bridge)은 [이 사이트](https://hdrihaven.com)에서
+가져온 사진입니다.
+
+<div class="threejs_center"><img src="../resources/images/equirectangularmaps/tears_of_steel_bridge_2k.jpg" style="width: 600px"></div>
+
+등장방형도법을 사용하려면 몇 가지를 추가해야 합니다. 별도의 `Scene`과
+`BoxBufferGeometry`를 만들고, 내장 쉐이더를 이용해 `ShaderMaterial`를
+만듭니다. 만든 요소들은 기존 장면을 렌더링하기 전 배경을 렌더링할 때
+사용할 겁니다.
+
+```js
+const bgScene = new THREE.Scene();
+let bgMesh;
+{
+  const loader = new THREE.TextureLoader();
+  const texture = loader.load('resources/images/equirectangularmaps/tears_of_steel_bridge_2k.jpg');
+  texture.magFilter = THREE.LinearFilter;
+  texture.minFilter = THREE.LinearFilter;
+
+  const shader = THREE.ShaderLib.equirect;
+  const material = new THREE.ShaderMaterial({
+    fragmentShader: shader.fragmentShader,
+    vertexShader: shader.vertexShader,
+    uniforms: shader.uniforms,
+    depthWrite: false,
+    side: THREE.BackSide,
+  });
+  material.uniforms.tEquirect.value = texture;
+  const plane = new THREE.BoxBufferGeometry(2, 2, 2);
+  bgMesh = new THREE.Mesh(plane, material);
+  bgScene.add(bgMesh);
+}
+```
+
+상자(BoxBufferGeometry)는 카메라의 `near`보다는 커야 하나, 동시에 `far`보다는
+작아야 합니다.
+
+또 상자의 안이 보여야 하니 `side: THREE.BackSide`로 설정하고, 깊이에 관한
+연산을 하지 않도록 `depthWrite: false`로 설정합니다.
+
+렌더링 시 배경 상자와 기존 카메라가 같은 위치에 있도록 설정하고, 배경용 장면을
+렌더링합니다.
+
+```js
+function render(time)
+
+    /* ... */
+
++    bgMesh.position.copy(camera.position);
++    renderer.render(bgScene, bgCamera);
+    renderer.render(scene, camera);
+```
+
+Three.js는 기본적으로 `renderer.render` 메서드를 호출 할 때마다 canvas를
+초기화합니다. 예제의 경우 `renderer.render`를 2번 호출하므로, 만약 설정을
+바꾸지 않으면 첫 결과물을 초기화할 테니 배경이 제대로 보이지 않겠죠. 설정을
+끄려면 `renderer.autoClearColor = false`를 설정하면 됩니다.
+
+```js
+const renderer = new THREE.WebGLRenderer({ canvas });
++renderer.autoClearColor = false;
+```
+
+{{{example url="../threejs-background-equirectangularmap.html" }}}
+
+등장방형도법은 복잡한 쉐이더를 사용하기에 큐브맵보다 성능이 떨어집니다.
+다행히 등장방형도법 이미지를 큐브맵으로 바꾸는 건 그다지 어려운 일이 아니죠.
+[이 사이트를 이용](https://matheowis.github.io/HDRI-to-CubeMap/)하면 쉽게
+이미지를 변경할 수 있을 겁니다.

+ 731 - 0
threejs/lessons/kr/threejs-load-gltf.md

@@ -0,0 +1,731 @@
+Title: Three.js에서 .GLTF 파일 불러오기
+Description: .GLTF 파일을 불러오는 법을 배웁니다
+TOC: .GLTF 파일 불러오기
+
+이전 글에서는 [.OBJ 파일을 불러오는 법](threejs-load-obj.html)에 대해
+배웠습니다. 이전 글을 읽지 않았다면 먼저 읽고 오길 권장합니다.
+
+이전 글에서 말했듯 .OBJ 파일은 굉장히 오래된 파일 형식이고 구성이 간단합니다.
+애초에 3D 에디터끼리 간단히 데이터를 주고 받을 것을 목적으로 만들었기에 씬
+그래프라는 개념이 없죠. 하나의 거대한 mesh입니다.
+
+[gLTF 형식](https://github.com/KhronosGroup/glTF)은 애초부터 그래픽 요소를
+표현하기 위해 설계된 파일 형식입니다. 3D 파일 형식은 크게 3, 4개 형식으로 나눌
+수 있죠.
+
+* 3D 에디터 형식
+
+  특정 프로그램을 위한 파일 형식입니다. .blend(블렌더), .max(3D Studio Max),
+  .mb, .ma(마야) 등이 있죠.
+
+* 교환 형식
+
+  여기에 .OBJ, .DAE(Collada), .FBX 등이 여기에 속합니다. 3D 에디터끼리 데이터를
+  교환하기 위해 고안된 형식으로, 보통 3D 에디터 내부에서 사용하는 것보다 더 많은
+  데이터를 포함합니다.
+
+* 앱 형식
+
+  특정 앱이나 게임 등에서 사용하는 파일 형식입니다.
+
+* 전달(transmission) 형식
+
+  glTF가 첫 전달 형식 파일입니다. 굳이 따지자면 VRML이 처음이라고 할 수도 있으나,
+  VRML은 부족한 점이 많습니다.
+
+  glTF는 기존 파일 형식에서 부진한 점을 보완한 형식으로, 크게 다음 면에서 기존 형식보다
+  뛰어납니다.
+
+  1. 전달 시 파일 용량 최적화
+
+    정점 등의 큰 데이터를 이진수(binary) 형태로 저장하는 것을 의미합니다. glTF 파일을
+    사용하면 별도의 가공 과정 없이 데이터를 GPU에 바로 로드할 수 있죠. 반면 VRML, .OBJ,
+    .DAE 등의 형식은 이런 데이터를 텍스트로 저장하여 파싱 과정이 필요합니다. 텍스트 기반의
+    정점 데이터는 이진수 데이터보다 3배에서 많게는 5배까지 큽니다.
+
+  2. 렌더링 최적화
+
+    앱 형식을 제외한 다른 파일 형식과 다른 점입니다. glTF 형식의 데이터는 수정이 아니라,
+    렌더링에 최적화되어 있습니다. 일반적으로 렌더링에 필요없는 데이터를 제거하는데, 예를
+    들어 다각형을 glTF 형식으로 저장하면 전부 삼각형으로 변환됩니다. 적용할 재질 데이터도
+    전부 지정되어 있죠.
+
+glTF는 특정 목적으로 고안되었기에 대부분의 경우 glTF 파일을 다운받아 사용하는 것은
+큰 문제가 없습니다. 다른 형식을 사용할 때는 대부분 조금씩 문제가 있었는데, 이번에는
+아니길 빌어봐야겠네요.
+
+사실 예제 하나로는 glTF를 전부 소개하기 어렵습니다. 단순한 glTF 파일은 .OBJ 파일보다
+사용하기가 쉬운 경우도 많고, .OBJ 파일과 달리 재질(materials)을 파일 안에 포함하거든요.
+그러니 파일을 하나 골라 불러오고, 이 과정에서 발생하는 문제를 해결하는 것이 더 도움이
+될 듯합니다.
+
+인터넷 검색으로 [로우-폴리(low-poly) 마을](https://sketchfab.com/models/edd1c604e1e045a0a2a552ddd9a293e6)을
+하나 찾았습니다(작가: [antonmoek](https://sketchfab.com/antonmoek)). 뭔가 괜찮은
+예제가 나올 것 같은 예감이 드네요.
+
+<div class="threejs_center"><img src="resources/images/cartoon_lowpoly_small_city_free_pack.jpg"></div>
+
+[.OBJ에 관한 글에서 썼던 예제](threejs-load-obj.html)를 가져와 .OBJ 파일을 불러오는
+코드를 .GLTF를 불러오는 코드로 바꾸겠습니다.
+
+아래의 기존 코드를
+
+```js
+const objLoader = new OBJLoader2();
+objLoader.loadMtl('resources/models/windmill/windmill-fixed.mtl', null, (materials) => {
+  materials.Material.side = THREE.DoubleSide;
+  objLoader.setMaterials(materials);
+  objLoader.load('resources/models/windmill/windmill.obj', (event) => {
+    const root = event.detail.loaderRootNode;
+    scene.add(root);
+    ...
+  });
+});
+```
+
+.GLTF를 불러오는 코드로 바꿉니다.
+
+```js
+{
+  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 문도 변경해야 합니다. `OBJLoader2`를 제거하고 `GLTFLoader`를
+추가합니다.
+
+```html
+-import { LoaderSupport } from './resources/threejs/r115/examples/jsm/loaders/LoaderSupport.js';
+-import { OBJLoader2 } from './resources/threejs/r115/examples/jsm/loaders/OBJLoader2.js';
+-import { MTLLoader } from './resources/threejs/r115/examples/jsm/loaders/MTLLoader.js';
++import { GLTFLoader } from './resources/threejs/r115/examples/jsm/loaders/GLTFLoader.js';
+```
+
+이제 실행해보죠.
+
+{{{example url="../threejs-load-gltf.html" }}}
+
+이런 걸 마법이라고 하나봅니다. 텍스처를 비롯해 모든 게 한 번에 완성됐네요.
+
+여기에 자동차가 도로를 따라 달리도록 할 수 있다면 더 멋있겠습니다. 먼저 장면(scene)에서
+차가 별도의 요소인지 확인하고, 별도의 요소라면 이 요소를 다룰 수 있는 방법을 찾아야 합니다.
+
+먼저 간단하게 함수를 만들어 씬 그래프를 [자바스크립트 콘솔](threejs-debugging-javascript.html)에
+띄워보겠습니다.
+
+```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;
+}
+```
+
+씬을 전부 불러온 뒤, 만든 함수를 호출합니다.
+
+```js
+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'));
+```
+
+[코드를 실행하니](../threejs-load-gltf-dump-scenegraph.html) 아래와 같은 결과가
+나왔습니다.
+
+```text
+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"`라는 부모의 자식이네요.
+
+```text
+*          ├─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" 요소를 참조해 변수로 저장합니다.
+
+```js
++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`의 자식 요소를 전부 회전시킵니다.
+
+```js
++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);
+}
+```
+
+잘 작동하는지 볼까요?
+
+{{{example url="../threejs-load-gltf-rotate-cars.html" }}}
+
+음... 기준축이 제각각인 것을 보니 이 파일을 만든 디자이너가 애니메이션까지
+고려하지는 않은 듯합니다. 트럭들이 전부 이상한 방향으로 도네요.
+
+이처럼 3D 프로젝트를 진행할 때는 목적에 따라 개체를 디자인해야 합니다. 그래야
+기준축이나, 크기 등이 제대로 적용될 테니까요.
+
+저는 디자이너도 아니고 블렌더를 그다지 잘 하지도 못하기에, 편법을 사용하겠습니다.
+각각의 자동차에 별도의 `Object3D`를 만들어 자동차를 이 `Object3D`의 자식으로
+지정할 겁니다. 이러면 자동차가 아닌 `Object3D`로 차를 움직일 수 있고, 자동차의
+기준축도 별도로 설정할 수 있죠.
+
+아까 봤던 씬 그래프를 다시 보니 자동차의 종류는 총 3개인 듯합니다. "Car_08",
+"CAR_03", "Car_04" 이렇게요. 종류별로 조정했을 때 제대로 적용되는지 봅시다.
+
+아래의 코드는 각 자동차를 새로운 `Object3D`의 자식으로 지정하고, 이 `Object3D`를
+장면에 추가한 뒤, 자동차의 *종류별로* 기준축을 정렬합니다. 그리고 새로 만든
+`Object3D`를 `cars` 배열에 추가하죠.
+
+```js
+-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);
++    }
+     ...
+```
+
+이제 기준축이 제대로 정렬되었습니다.
+
+{{{example url="../threejs-load-gltf-rotate-cars-fixed.html" }}}
+
+이제 자동차를 달리게 만들어봅시다.
+
+간단한 이동 시스템이라고 해도 튜토리얼에서 다루기에는 다소 복잡합니다. 대신
+도로 전체를 달리는 뒤얽힌 경로를 만들어 자동차를 해당 경로에 놓을 수는 있죠.
+아래 스크린샷은 경로를 반쯤 완성했을 때 블렌더의 화면을 캡쳐한 것입니다.
+
+<div class="threejs_center"><img src="resources/images/making-path-for-cars.jpg" style="width: 1094px"></div>
+
+이제 블렌더에서 데이터를 추출해야 합니다. 다행히 경로만을 골라 내보낼 수 있네요.
+"write nurbs"를 체크해 경로를 .OBJ 파일로 내보냅니다.
+
+<div class="threejs_center"><img src="resources/images/blender-export-obj-write-nurbs.jpg" style="width: 498px"></div>
+
+.OBJ 파일을 열어보니 각 정점 데이터가 있습니다. 이를 배열로 바꿔 사용하도록
+하죠.
+
+```js
+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`가
+적당하겠네요. 이런 곡선은 각 정점을 지나는 부드러운 곡선을 만든다는 것이
+특징입니다.
+
+만약 위 정점으로 곡선을 생성하면 다음 그림과 같은 곡선이 생길 겁니다.
+
+<div class="threejs_center"><img src="resources/images/car-curves-before.png" style="width: 400px"></div>
+
+모서리가 각지면 좀 더 깔끔할 듯하네요. 정점을 몇 개 더 추가해 원하는
+결과를 만들어봅시다. 각 정점 짝마다 10% 아래에 하나, 두 정점 사이 90%
+지점에 하나를 새로 만들어 `CatmullRomCurve3`에 넘겨주겠습니다.
+
+우리가 원하는 곡선은 다음과 같죠.
+
+<div class="threejs_center"><img src="resources/images/car-curves-after.png" style="width: 400px"></div>
+
+아래는 곡선을 생성하는 코드입니다.
+
+```js
+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개의
+정점을 받은 뒤, 이 정점들을 이어 곡선을 시각화합니다.
+
+하지만 [예제](../threejs-load-gltf-car-path.html)를 실행하니 곡선이 보이지
+않습니다. 일단 어디에 있는지 확인하기 위해 깊이 테스트(depth test) 옵션을
+끄고, 마지막에 렌더링하도록 설정하겠습니다.
+
+```js
+    curveObject = new THREE.Line(geometry, material);
++    material.depthTest = false;
++    curveObject.renderOrder = 1;
+```
+
+다시 예제를 실행해보니 곡선이 너무 작은 게 문제였네요.
+
+<div class="threejs_center"><img src="resources/images/car-curves-too-small.png" style="width: 498px"></div>
+
+블렌더로 계층 구조를 확인해보니 디자이너가 자동차 부모의 스케일(scale)을
+건드렸습니다.
+
+<div class="threejs_center"><img src="resources/images/cars-scale-0.01.png" style="width: 342px;"></div>
+
+실제 3D 앱에서 스케일을 건드리는 것은 좋지 않습니다. 갖은 문제를 일으켜
+개발자를 좌절의 굴레에 빠지게 하거든요. 디자이너 입장에서야 각각의 크기를
+직접 수정하는 것보다 전체의 스케일을 조정하는 게 훨씬 편하겠지만, 실제 3D
+앱 프로젝트에 참여한다면 디자이너에게 스케일을 건드리지 말라고 요청하기
+바랍니다. 만약 디자이너가 스케일을 수정해야만 하는 경우라면, 앱을 만들 때
+스케일을 무시할 수 있도록 정점에까지 스케일을 적용할 수 있는 방법을 찾아야
+합니다.
+
+이 예제의 경우는 스케일뿐만 아니라 자동차들의 회전값과 위치값까지 `Cars`
+요소의 영향을 받습니다. 이러면 자동차가 돌아다니게 만들기가 훨씬 어렵죠.
+물론 예제의 경우 차를 전역 공간 안에서 움직여야 하기에 어려움이 있지만,
+지역 공간에서만 무언가를 조작하는 경우, 예를 들어 지구를 도는 달을 구현하는
+경우는 이런 것이 큰 걸림돌이 되진 않습니다.
+
+씬 그래프를 출력하기 위해 썼던 코드를 다시 가져와 이번에는 각 요소의
+위치값(position), 회전값(rotation), 크기값(scale)까지 출력해봅시다.
+
+```js
++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;
+}
+```
+
+[코드를 실행](../threejs-load-gltf-dump-scenegraph-extra.html)하니
+다음과 같은 결과가 나옵니다.
+
+```text
+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 파일을 만들었기 때문일 겁니다.
+
+진작 .blend 파일을 받아 직접 .GLTF 파일을 만들었더라면 좋았을 거라는 생각이
+듭니다. 내보내기 전에 주요 요소를 점검해 불필요한 설정을 제거했으면 더
+좋았을 텐데 말이죠.
+
+아래의 이 요소들도
+
+```text
+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"와 루트 요소가 자동차를 찾는 데 도움이 될 수는 있으나, 이 역시 별도의 위치값,
+회전값, 크기값이 없는 게 나으니 간단히 장면을 자동차의 부모로 지정하는 것은요?
+
+가장 최선의 해결책은 아니지만, 곡선 자체의 크기를 키우는 게 가장 빠른 해결책이긴
+합니다.
+
+일단 저는 마지막 해결책을 선택하겠습니다.
+
+먼저 곡선의 위치를 옮겨 적당한 위치에 둔 뒤 곡선을 숨깁니다.
+
+```js
+{
+  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`를 이용해 전역 공간에서의 위치값을
+구합니다. 그리고 곡선의에서 조금 더 낮은 값을 구한 뒤, `looAt` 메서드를 이용해
+자동차가 이 점을 바라보도록 설정하고, 자동차를 위치값과 방금 구한 점 중간에 둡니다.
+
+```js
+// 경로를 계산할 때 쓸 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);
++    });
++  }
+```
+
+실행시켜보니 자동차의 높이 기준도 제각기네요. 각 자동차의 위치값을 조금씩 수정하겠습니다.
+
+```js
+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);
+}
+```
+
+{{{example url="../threejs-load-gltf-animated-cars.html" }}}
+
+몇 분 투자한 것 치고는 괜찮은 결과물이네요!
+
+마지막으로 그림자까지 추가하면 완벽할 것 같습니다.
+
+[그림자에 관한 글](threejs-shadows.html)의 `DirectionalLight` 그림자 예제를 가져와
+그대로 코드에 붙여 넣습니다.
+
+그리고 파일을 불러온 뒤, 모든 요소의 그림자 설정을 켜줍니다.
+
+```js
+{
+  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의 그림자 설정을
+켜주지 않았기 때문이죠.
+
+```js
+renderer.shadowMap.enabled = true;
+```
+
+이 간단한 걸 해결하느라 무려 4시간이 걸렸다는 건 비밀입니다. 😭
+
+그리고 `DirectionLight`의 그림자용 카메라가 장면 전체를 투사하도록 절두체를
+조정합니다. 다음과 같이요.
+
+```js
+{
+  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;
+...
+```
+
+마지막으로 배경색을 옅은 하늘색으로 설정합니다.
+
+```js
+const scene = new THREE.Scene();
+-scene.background = new THREE.Color('black');
++scene.background = new THREE.Color('#DEFEFF');
+```
+
+{{{example url="../threejs-load-gltf-shadows.html" }}}
+
+이 글이 씬 그래프를 포함한 파일을 불러오고, 몇몇 문제를 해결하는 데 도움이
+되었으면 합니다.
+
+.blend 파일과 .gltf 파일을 비교해보면 재미있는 점이 하나 있습니다. .blend
+파일에는 몇 가지 조명 요소가 있지만 씬 그래프로 변환하면 더 이상 조명의 역할을
+하지 못한다는 거죠. .GLTF 파일은 단순한 JSON 형태의 파일이기에 쉽게 내용을
+열어 볼 수 있습니다. .GLTF에는 여러 배열이 있고 이 배열의 요소를 참조할 때는
+인덱스값을 사용하죠. 기능을 확장하기에 쉬운 형식이라고는 해도, 다른 3D 형식과
+마찬가지로 **존재하는 모든 기능을 지원하지는 못합니다**.
+
+때문에 항상 추가 데이터가 필요합니다. 아까 자동차가 따라갈 경로를 따로 내보낸
+것도 한 예죠. 경로까지 .GLTF 파일에 포함하는 게 이상적이긴 하나, 그러려면
+내보내기 규칙도 작성해야 하고, 규칙을 적용하기 위해 각 요소에 이름을 따로
+부여하든지, 네이밍 스키마를 사용하든지, 하여간 데이터를 앱에서 사용하기 위해
+무언가 해야 합니다.
+
+어떤 게 가장 좋은 방법일까요? 제 생각에 이건 전적으로 여러분이 풀어나가야 할
+숙제입니다. 상황에 따라서 많이 달라질 테니까 말이죠.
+

+ 651 - 0
threejs/lessons/kr/threejs-load-obj.md

@@ -0,0 +1,651 @@
+Title: Three.js에서 .OBJ 파일 불러오기
+Description: .OBJ 파일을 불러오는 법을 배웁니다
+TOC: .OBJ 파일 불러오기
+
+Three.js로 프로젝트를 진행할 때, 3D 모델 파일을 불러와 사용하는 것은
+아주 흔한 일입니다. 오늘은 주로 사용하는 파일 형식인 .OBJ 파일을
+불러오는 법에 대해 알아보겠습니다.
+
+인터넷을 검색해 [CC-BY-NC 3.0 풍자 3D 모델](https://www.blendswap.com/blends/view/69174)을
+하나 가져왔습니다(작가: [ahedov](https://www.blendswap.com/user/ahedov)).
+
+<div class="threejs_center"><img src="resources/images/windmill-obj.jpg"></div>
+
+다운받은 파일 형식이 .blend네요. [블렌더(Blender)](https://blender.org)로
+파일을 열어 .OBJ 형식으로 변환하겠습니다.
+
+<div class="threejs_center"><img style="width: 827px;" src="resources/images/windmill-export-as-obj.jpg"></div>
+
+> 블렌더는 다른 프로그램과 다른 점이 많아 낯설게 느껴질 수 있습니다.
+블렌더를 처음 접한다면, 글 읽기를 잠시 멈추고 블렌더의 기본 UI 가이드를
+먼저 읽어보길 권장합니다.
+
+> 추가로 보통 3D 프로그램은 수천 가지 기능을 지원하는 거대 함선과 같습니다.
+프로그램들 중에서도 복잡하기로 유명하죠. 1996년, 제가 3D Studio Max를 처음
+배우기 시작했을 때 저는 하루에 몇 시간씩 3주를 들여 공식 매뉴얼의 70% 정도를
+정독했습니다. 그리고 그게 몇 년 뒤 마야(Maya)를 배울 때 도움이 많이 됐죠. 3D
+모델을 만들든, 기존 모델을 수정하든, 3D 프로그램으로 무언가를 하고 싶다면 강의나
+튜토리얼에 따로 시간을 투자하기 바랍니다.
+
+특별한 일이 없다면 저는 파일을 내보낼 때 아래의 옵션을 사용합니다.
+
+<div class="threejs_center"><img style="width: 239px;" src="resources/images/windmill-export-options.jpg"></div>
+
+자 이제 한 번 화면에 띄워보죠!
+
+[조명에 관한 글](threejs-lights.html)에서 썼던 예제를 가져와 이 예제를
+반구광(hemisphere light) 예제와 합칩니다. 그러면 장면에는 `HemisphereLight`
+하나, `DirectionalLight` 하나가 있는 셈입니다. 또 GUI 관련 코드와 정육면체,
+구체 관련 코드도 지웁니다.
+
+다음으로 먼저 `OBJLoader2` 모듈을 스크립트에 로드합니다.
+
+```js
+import { OBJLoader2 } from './resources/threejs/r115/examples/jsm/loaders/OBJLoader2.js';
+```
+
+`OBJLoader2`의 인스턴스를 생성한 뒤 .OBJ 파일의 경로와 콜백 함수를 넘겨
+`load` 메서드를 실행합니다. 그리고 콜백 함수에서 불러온 모델을 장면에
+추가합니다.
+
+```js
+{
+  const objLoader = new OBJLoader2();
+  objLoader.load('resources/models/windmill/windmill.obj', (root) => {
+    scene.add(root);
+  });
+}
+```
+
+어떤 결과가 나올까요?
+
+{{{example url="../threejs-load-obj-no-materials.html" }}}
+
+뭔가 성공한 듯하지만 재질(materials)이 없어 오류가 납니다. .OBJ 파일에도
+재질이 없고 따로 재질을 지정하지도 않았기 때문이죠.
+
+위에서 생성한 .OBJ 로더에는 이름 : 재질 쌍을 객체로 지정할 수 있습니다.
+.OBJ 파일을 불러올 때, 이름이 지정되었다면 로더에 지정한 재질 중에 이름(키)과
+일치하는 재질을 찾아 사용하고, 재질을 찾지 못했다면 기본 재질을 사용하죠.
+
+.OBJ 파일을 생성할 때 재질에 대한 데이터를 담은 .MTL 파일이 같이 생성되기도
+합니다. 방금의 경우에도 .MTL 파일이 같이 생성되었죠. MTL 파일은 ASCII 인코딩이므로
+일반 텍스트 파일처럼 열어볼 수 있습니다.
+
+```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
+
+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
+```
+
+파일을 살펴보면 2개의 재질과 5개의 jpg 텍스처가 보이는데, 텍스처 파일은
+디렉토리 내에 보이지 않습니다. 대체 어디에 있는 걸까요?
+
+<div class="threejs_center"><img style="width: 757px;" src="resources/images/windmill-exported-files.png"></div>
+
+생성된 거라고는 .OBJ 파일 하나와 .MTL 파일 하나 뿐입니다.
+
+사실 방금 사용한 모델의 텍스처는 .blend 파일에 포함되어 있습니다.
+**File->External Data->Unpack All Into Files**를 선택하고
+
+<div class="threejs_center"><img style="width: 828px;" src="resources/images/windmill-export-textures.jpg"></div>
+
+**Write Files to Current Directory**를 선택해 텍스처를 별도 파일로
+내보낼 수 있습니다.
+
+<div class="threejs_center"><img style="width: 828px;" src="resources/images/windmill-overwrite.jpg"></div>
+
+이러면 .blend 파일과 같은 경로의 **textures** 폴더 안에 텍스처 파일이
+생성됩니다.
+
+<div class="threejs_center"><img style="width: 758px;" src="resources/images/windmill-exported-texture-files.png"></div>
+
+내보낸 텍스처를 복사해 .OBJ 파일과 같은 경로에 두겠습니다.
+
+<div class="threejs_center"><img style="width: 757px;" src="resources/images/windmill-exported-files-with-textures.png"></div>
+
+이제 .MTL 파일에서 사용할 텍스처를 생성했으니 .MTL 파일을 불러오도록 합시다.
+
+`MTLLoader`와 `MTLObjBridge` 모듈을 불러옵니다.
+
+```js
+import * as THREE from './resources/three/r115/build/three.module.js';
+import { OrbitControls } from './resources/threejs/r115/examples/jsm/controls/OrbitControls.js';
+import { OBJLoader2 } from './resources/threejs/r115/examples/jsm/loaders/OBJLoader2.js';
++import { MTLLoader } from './resources/threejs/r115/examples/jsm/loaders/MTLLoader.js';
++import { MtlObjBridge } from './resources/threejs/r115/examples/jsm/loaders/obj2/bridge/MtlObjBridge.js';
+```
+
+우선 .MTL 파일을 불러와 `MtlObjBridge`로 재질을 만듭니다. 그리고 `OBJLoader2`
+인스턴스에 방금 만든 재질을 추가한 뒤 .OBJ 파일을 불러옵니다.
+
+```js
+{
++  const mtlLoader = new MTLLoader();
++  mtlLoader.load('resources/models/windmill/windmill.mtl', (mtlParseResult) => {
++    const materials =  MtlObjBridge.addMaterialsFromMtlLoader(mtlParseResult);
++    objLoader.addMaterials(materials);
+    objLoader.load('resources/models/windmill/windmill.obj', (root) => {
+      scene.add(root);
+    });
++  });
+}
+```
+
+{{{example url="../threejs-load-obj-materials.html" }}}
+
+얼핏 제대로 불러온 것 같지만 아직 부족한 점이 있습니다. 모델을 이리저리
+회전시켜 보면 풍차의 날개 뒷면이 사라지는 것을 볼 수 있을 겁니다.
+
+<div class="threejs_center"><img style="width: 528px;" src="resources/images/windmill-missing-cloth.jpg"></div>
+
+[재질에 관한 글](threejs-materials.html)을 읽었다면 원인이 무엇인지 알
+겁니다. 일단 풍차의 날개 양면을 모두 렌더링하도록 설정해야 겠네요. .MTL
+파일을 직접 수정하기는 어렵습니다. 그렇다면 쉽게 떠올릴 수 있는 방법은
+3가지 정도죠.
+
+1. 모든 재질을 불러온 뒤 반복문으로 처리한다.
+
+        const mtlLoader = new MTLLoader();
+        mtlLoader.load('resources/models/windmill/windmill.mtl', (mtlParseResult) => {
+          const materials =  MtlObjBridge.addMaterialsFromMtlLoader(mtlParseResult);
+          for (const material of Object.values(materials)) {
+            material.side = THREE.DoubleSide;
+          }
+          ...
+
+  문제가 해결되긴 하겠지만, 양면 렌더링은 단면 렌더링에 비해 성능이 느립니다.
+  양면일 필요가 있는 재질만 양면으로 렌더링하는 게 이상적이겠죠.
+
+2. 특정 재질을 골라 설정한다.
+
+  .MTL 파일에는 `"windmill"`, `"Material"` 2개의 재질이 있습니다. 여러 번의 시도와
+  에러 끝에 날개가 `"Material"`이라는 이름의 재질을 쓴다는 것을 알아낸 뒤, 이 재질에만
+  양면 속성을 설정할 수도 있을 겁니다.
+
+        const mtlLoader = new MTLLoader();
+        mtlLoader.load('resources/models/windmill/windmill.mtl', (mtlParseResult) => {
+          const materials =  MtlObjBridge.addMaterialsFromMtlLoader(mtlParseResult);
+          materials.Material.side = THREE.DoubleSide;
+          ...
+
+3. .MTL 파일의 한계에 굴복하고 직접 재질을 만든다.
+
+        const materials = {
+          Material: new THREE.MeshPhongMaterial({...}),
+          windmill: new THREE.MeshPhongMaterial({...}),
+        };
+        objLoader.setMaterials(materials);
+
+뭘 선택하든 그건 여러분의 선택입니다. 1번이 가장 간단하고, 3번이 가장
+확장성이 좋죠. 2번은 그 중간입니다. 지금은 2번 해결책을 사용하도록 하죠.
+
+해결책을 적용하면 날개가 제대로 보일 겁니다. 하지만 문제가 하나 더 남았습니다.
+모델을 확대해보면 텍스처가 굉장히 각져 보일 거예요.
+
+<div class="threejs_center"><img style="width: 700px;" src="resources/images/windmill-blocky.jpg"></div>
+
+뭐가 문제일까요?
+
+텍스처 파일 중에는 NOR, 법선 맵(NORmal map)이라는 이름이 붙은 파일이 있습니다.
+이 파일이 바로 법선 맵이죠. 범프 맵(bump map)이 흑백이라면 법선 맵은 보통
+자주색을 띱니다. 범프 맵이 표면의 높이를 나타낸다면 법선 맵은 표면의 방향을
+나타내죠.
+
+<div class="threejs_center"><img style="width: 256px;" src="../resources/models/windmill/windmill_001_base_NOR.jpg"></div>
+
+[MTLLoader의 소스 코드](https://github.com/mrdoob/three.js/blob/1a560a3426e24bbfc9ca1f5fb0dfb4c727d59046/examples/js/loaders/MTLLoader.js#L432)를
+살펴보면 법선 맵의 키(key)가 `norm`이어야 한다고 합니다. 간단히 .MTL 파일을
+수정해보죠.
+
+```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
+```
+
+이제 법선 맵이 정상적으로 적용되었고, 날개의 뒷면도 제대로 보입니다.
+
+{{{example url="../threejs-load-obj-materials-fixed.html" }}}
+
+다른 파일도 불러와봅시다.
+
+인터넷을 뒤져 [CC-BY-NC](https://creativecommons.org/licenses/by-nc/4.0/)
+풍차 3D 모델을 발견했습니다(작가: [Roger Gerzner / GERIZ.3D Art](http://www.gerzi.ch/)).
+
+<div class="threejs_center"><img src="resources/images/windmill-obj-2.jpg"></div>
+
+.OBJ 형식으로 다운 받을 수 있으므로, 해당 형식으로 받아 불러오겠습니다(잠깐
+.MTL 로더를 제거했습니다).
+
+```js
+-  objLoader.load('resources/models/windmill/windmill.obj', ...
++  objLoader.load('resources/models/windmill-2/windmill.obj', ...
+```
+
+{{{example url="../threejs-load-obj-wat.html" }}}
+
+음, 아무것도 나타나지 않습니다. 뭐가 문제일까요? 모델의 원래 크기 때문일까요?
+Three.js로부터 모델 사이즈를 구해 카메라를 한 번 업데이트해보겠습니다.
+
+먼저 Three.js가 방금 불러온 모델을 감싸는 육면체를 계산해 모델의 크기와 중심점을
+구하는 코드를 작성합니다.
+
+```js
+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);
+```
+
+[자바스크립트 콘솔](threejs-debugging-javascript.html)을 확인해보면 아래와
+같은 결과가 보일 겁니다.
+
+```js
+size 2123.6499788469982
+center p { x: -0.00006103515625, y: 770.0909731090069, z: -3.313507080078125 }
+```
+
+이 카메라는 현재 `near` 0.1, `far`가 100 이므로 약 100칸 정도를 투사합니다.
+땅도 40x40칸인데 이 모델은 2000칸이죠. 카메라의 시야보다 훨씬 크니 절두체 영역
+밖에 있는 게 당연합니다.
+
+<div class="threejs_center"><img style="width: 280px;" src="resources/images/camera-inside-windmill.svg"></div>
+
+수작업으로 고칠 수도 있지만, 카메라가 장면의 크기를 자동으로 감지하도록 만들어보겠습니다.
+방금 모델의 크기를 구할 때 썼던 육면체를 이용하면 되겠네요. 카메라의 위치를 정하는 데
+*정해진* 방법은 없습니다. 경우에 따라 카메라의 방향과 위치가 다르니 그때 그때 상황에
+맞춰 방법을 찾아야 하죠.
+
+[카메라에 관해 배운 내용](threejs-cameras.html)을 떠올려봅시다. 카메라를 만들려면
+절두체를 정의해야 하죠. 절두체는 `fov(시야각, field of view)`, `near`, `far` 속성을
+지정해 정의합니다. 시야각이 얼마이든, 절두체가 무한히 늘어난다고 가정할 때, 장면을
+둘러싼 육면체가 절두체 안에 들어오게 하려면 카메라를 얼마나 멀리 보내야 할까요? 그러니까
+`near`가 0.00000001이고 `far`가 무한대라 가정했을 때 말이죠.
+
+다행히 시야각과 육면체의 크기를 아니 다음 그림과 같은 삼각형을 사용할 수 있습니다.
+
+<div class="threejs_center"><img style="width: 600px;" src="resources/images/camera-fit-scene.svg"></div>
+
+그림에서 왼쪽은 카메라이고, 카메라에서 뻗어나온 파란 절두체가 풍차를 투사합니다.
+방금 풍차를 둘러싼 육면체의 위치값을 계산했죠. 이제 얼마나 카메라를 멀리 보내야
+육면체가 절두체 안에 들어올지 계산해야 합니다.
+
+절두체의 시야각과 육면체의 크기를 구했으니, 기본 삼각함수와 [*SOHCAHTOA](https://www.google.com/search?q=SOHCAHTOA)를
+이용해 카메라와 육면체의 *거리(distance)*를 구할 수 있습니다.
+
+※ SOH-CAH-TOA: 한국에서 삼각함수를 배울 때 얼싸안코와 비슷한 식으로 외우듯,
+영미권에도 삼각함수를 배울 때 Sin = Opposite(대변) 나누기 Hypotenuse(빗변),
+Cos = Adjacent(밑변) 나누기 Hypotenuse, Tan = Oppsite 나누기 Adjacent와 같은
+식으로 외웁니다. 이를 줄여서 SOH-CAH-TOA(소-카-토아)라고 부릅니다. 역주.
+
+<div class="threejs_center"><img style="width: 600px;" src="resources/images/field-of-view-camera.svg"></div>
+
+그림을 기반으로 계산식을 짜보겠습니다.
+
+```js
+distance = halfSizeToFitOnScreen / tangent(halfFovY) // 거리 = 화면 크기의 반 / 탄젠트(시야각의 절반)
+```
+
+이제 위 계산식을 코드로 옮겨야 합니다. 먼저 `distance(거리)`를 구한 뒤
+카메라를 육면체의 중심에서 `distance`값만큼 옮깁니다. 그리고 카메라가 육면체의
+`center(중심)`을 바라보게 설정합니다.
+
+```js
+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);
+}
+```
+
+이 함수는 `boxSize`와 `sizeToFitOnScreen`, 두 개의 크기값을 매개변수로 받습니다.
+`boxSize` 값으로 `sizeToFitOnScreen` 값을 대체할 수도 있지만, 이러면 육면체가
+화면에 꽉 차게 됩니다. 조금 여유가 있는 편이 보기 편하므로 조금 더 큰 값을 넘겨주도록
+하겠습니다.
+
+```js
+{
+  const objLoader = new OBJLoader2();
+  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`도 업데이트했죠.
+
+이제 코드를 실행하면...
+
+{{{example url="../threejs-load-obj-auto-camera.html" }}}
+
+성공했습니다. 마우스로 장면을 드래그하면 풍차가 보일 거예요. 하지만 카메라가 풍차의
+정면이 아닌 아래쪽을 먼저 보여줍니다. 이는 풍차가 너무 커서 육면체의 중심이 약
+(0, 770, 0)인데, 카메라를 육면체의 중심에서 기존 위치 (0, 10, 20) 방향으로 `distance`만큼
+옮겼기에 풍차의 아래쪽에 카메라가 위치하게 된 것입니다.
+
+<div class="threejs_center"><img style="width: 360px;" src="resources/images/computed-camera-position.svg"></div>
+
+카메라의 기존 위치에 상관없이 육면체의 중심을 기준으로 카메라를 배치해보겠습니다.
+단순히 카메라와 육면체 간 벡터의 `y` 요소를 0으로 만들면 됩니다. `y` 요소를 0으로
+만든 뒤 벡터를 정규화(normalize)하면, XZ 면에 평행한 벡터, 그러니까 바닥에 평행한
+벡터가 되겠죠.
+
+```js
+-// 육면체의 중심에서 카메라가 있는 곳으로 향하는 방향 벡터를 계산합니다
+-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();
+```
+
+풍차의 아랫면을 보면 작은 정사각형이 하나 보일 겁니다. 원래 땅으로 썼던 평면이죠.
+
+<div class="threejs_center"><img style="width: 365px;" src="resources/images/tiny-ground-plane.jpg"></div>
+
+원래 땅은 40x40칸이었으니 풍차에 비해 훨씬 작은 것이 당연합니다. 풍차의 크기는
+2000칸이 넘습니다. 땅을 풍차에 맞게 키워야 겠네요. 또 크기만 키우면 체크무늬가
+너무 작아 확대하지 않는 한 보기가 어려울 테니 체스무늬 한 칸의 크기도 키우겠습니다.
+
+```js
+-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);
+```
+
+{{{example url="../threejs-load-obj-auto-camera-xz.html" }}}
+
+이제 재질을 다시 붙여봅시다. 이전 모델과 마찬가지로 텍스처에 대한 데이터를 담은
+.MTL 파일이 보입니다. 하지만 동시에 다른 문제도 보이네요.
+
+```shell
+ $ 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 파일을 사용할 수도 있죠.([*](#loading-scenes))
+
+TGA 파일의 문제점 중 하나는 압축을 거의 하지 않는다는 점입니다. TGA는 아주 간단한
+압축만 지원하죠. 파일의 크기가 모두 같을 확률은 매우 희박하니, 위 파일들은 아예
+압축이 되지 않았다고 볼 수 있습니다. 게다가 파일 하나당 무려 12 메가바이트!! 저
+파일을 그대로 사용한다면 사용자는 풍차 하나를 보기 위해 36MB의 데이터를 다운받아야
+하는 셈이 됩니다.
+
+또한 브라우저가 TGA를 지원하지 않기에, .JPG나 .PNG 파일보다 로딩 시간이 훨씬 느릴
+겁니다.
+
+확신하건데, 이 경우 .JPG 파일로 변환하는 게 가장 좋은 선택입니다. TGA 파일은 알파값이
+없는 RGB 3개의 채널로 구성되죠. JPG도 채널 3개만 사용하니 딱 적당합니다. 또 JPG는 손실
+압축을 사용하기에 파일 용량을 훨씬 많이 줄일 수 있습니다.
+
+파일을 열어보니 각각 해상도가 2048x2048입니다. 쓰기에 따라 다르겠지만, 저는 이게 다소
+낭비라는 생각에 해상도를 1024x1024로 낯추고 포토샵의 퀄리티 설정을 50%로 지정했습니다.
+다시 파일 구조를 살펴보죠.
+
+```shell
+ $ 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
+```
+
+36MB에서 0.55MB가 되었네요! 물론 디자너이너의 생각은 다를 수 있으니 절충안을
+찾기 전에 상의를 하는 것이 좋습니다.
+
+이제 .MTL 파일을 열어 .TGA 파일 경로를 .JPG 파일로 바꿉니다. 다행히 .MTL 파일은
+텍스트라 수정이 어렵지 않습니다.
+
+```mtl
+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 
+```
+
+텍스처의 용량을 최적화했으니 이제 불러올 일만 남았습니다. 먼저 아까 했던 것처럼
+재질을 불러와 `OBJLoader2`에 지정합니다.
+
+```js
+{
++  const mtlLoader = new MTLLoader();
++  mtlLoader.load('resources/models/windmill_2/windmill-fixed.mtl', (mtlParseResult) => {
++    const objLoader = new OBJLoader2();
++    const materials =  MtlObjBridge.addMaterialsFromMtlLoader(mtlParseResult);
++    objLoader.addMaterials(materials);
+    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`가 각각 디퓨즈(diffuse) 색과 디퓨즈 텍스처 맵으로
+혼합하는 재질을 만듬.
+
+이는 유용한 기능이지만, .MTL 파일의 디퓨즈 색상은 0입니다.
+
+```mtl
+Kd 0.00 0.00 0.00
+```
+
+(텍스처 맵 * 0 = 검정)이죠. 모델링 프로그램에서는 디퓨즈 텍스처 맵과 디퓨즈 색을 혼합하지
+않아도 모델이 제대로 보입니다. 이 풍차를 만든 디자이너 입장에서는 이 파일이 문제가
+없다고 생각하는 것이 당연하죠.
+
+.MTL 파일을 다음과 같이 수정해 문제를 해결할 수 있습니다.
+
+```mtl
+Kd 1.00 1.00 1.00
+```
+
+(텍스처 맵 * 1 = 텍스처 맵)이니까요.
+
+문제 #2: 스페큘러(specular) 색이 검정임.
+
+`Ks`로 시작하는 줄은 스페큘러 색을 나타냅니다. 이 역시 디자이너가 사용한 모델링
+프로그램이 디퓨즈 맵처럼 뭔가 다른 처리를 해주었을 겁니다. Three.js는 스페큘러
+색을 얼마나 많이 반사할지 결정할 때 스페큘러 맵의 빨강(red) 채널만 사용하기는
+하나, 3가지 색상 채널을 모두 지정하긴 해야 합니다.
+
+디퓨즈 색과 마찬가지로 .MTL 파일을 다음과 같이 수정하겠습니다.
+
+```mtl
+-Ks 0.00 0.00 0.00
++Ks 1.00 1.00 1.00
+```
+
+문제 #3: `windmill_normal.jpg`가 법선 맵이 아닌 범프 맵임.
+
+마찬가지로 .MTL 파일을 수정해줍니다.
+
+```mtl
+-map_bump windmill_normal.jpg 
+-bump windmill_normal.jpg 
++norm windmill_normal.jpg 
+```
+
+위 변경 사항을 모두 반영하면 재질이 정상적으로 적용될 겁니다.
+
+{{{example url="../threejs-load-obj-materials-windmill2.html" }}}
+
+모델을 불러올 때 주의해야 하는 점을 몇 가지만 적어보겠습니다.
+
+* 크기를 알아야 한다
+
+  예제에서는 카메라가 장면 전체를 감싸도록 했지만, 이게 항상 최적의 해결책이 될 수는 없습니다.
+  직접 모델을 만들거나, 모델을 다운받아 3D 프로그램으로 크기를 조절하는 것이 더 이상적인 방법입니다.
+
+* 잘못된 방향축
+
+  Three.js에서는 보통 Y축이 위쪽입니다. 모델링 프로그램에서는 Z축이 위쪽인 경우, Y축이 위쪽인 경우, 직접
+  설정할 수 있는 경우 등 경우가 다양하죠. 모델을 불러왔는데 방향이 잘못되었다면, 모델을 불러온 후 방향을
+  바꾸거나(권장하지 않음), 3D 프로그램이나 커맨드 라인 프로그램으로 모델을 원하는 방향으로 맞출 수 있습니다.
+  브라우저에서 이미지를 쓸 때와 마찬가지로, 이미지를 수정하는 코드를 넣는 것보다는 이미지를 다운받아 이미지
+  자체를 편집하는 게 더 나을 겁니다. 블렌더에서는 아예 파일을 내보낼 때 방향을 바꿀 수 있습니다.
+
+* .MTL 파일이 없거나 재질 또는 지원하지 않는 값이 있는 경우
+
+  위 예제를 만들 때 .MTL 파일 덕에 재질을 만드는 수고는 덜었지만, 몇 가지 문제가 있었습니다. 문제를 해결하기
+  위해 직접 .MTL 파일을 수정했고요. 파일을 열어 .OBJ 파일 안에 어떤 재질이 있는지 확인하거나, Three.js로
+  .OBJ 파일을 불러와 재질을 전부 출력하도록 하는 것은 꽤 자주 있는 일입니다. 그런 후에 .MTL 파일 대신 직접
+  재질을 만들어 적절한 이름/재질 쌍의 객체로 로더에 넘겨주거나, 장면을 렌더링한 뒤 테스트하면서 문제를 수정하는
+  것이죠.
+
+* 고용량 텍스처
+
+  3D 모델은 주로 건축, 영화나 광고, 게임 등에서 사용합니다. 건축이나 영화 같은 분야라면 텍스처의 용량을
+  신경 쓸 필요는 없죠. 반면 게임의 경우는 메모리도 제한적이고 로컬 환경에서 구동되기에 신경을 꽤 써야 합니다.
+  웹 페이지의 경우는 빠르게 불러와야 하니 용량이 퀄리티가 너무 떨어지지 않는 선에서 최대한 작은 게 좋죠.
+  첫 번째로 쓴 풍차의 경우, 실제로 사용하려면 텍스처를 손볼 필요가 있습니다. 지금은 총 용량이 무려 10MB가
+  넘거든요!!!
+
+  또한 [텍스처에 관한 글](threejs-textures.html)에서 말했듯, 텍스처의 해상도도 고려해야 합니다. 50KB짜리
+  4096x4096 JPG 이미지는 불러오는 속도는 빠를지 몰라도 굉장히 많은 메모리를 차지할 테니까요.
+
+마지막으로 풍차가 돌아가는 것을 보여주고 싶지만, .OBJ 파일에는 계층 구조가 없습니다. 다시 말해 풍차의 모든
+요소를 기본적으로 1개의 mesh로 취급한다는 것이죠. 풍차의 날개를 건물에서 분리할 수 없기에 날개를 회전시킬
+수가 없습니다.
+
+이런 이유로 .OBJ는 그다지 좋은 파일 형식이라고 하기 어렵습니다. 추측하건데 .OBJ 형식을 자주 사용하는 이유는
+사용법이 간단하고, 복잡한 기능이 필요 없는 경우가 많기 때문일 겁니다. 예를 들어 건축 디자인을 하는 경우,
+대부분 애니메이션이 필요 없기에 장면에 정적 요소를 추가하는 게 더 좋을 수 있죠.
+
+.gLTF는 .OBJ보다 더 많은 기능을 지원합니다. 다음 글에서는 이 gLTF 장면을 불러오는 법에 대해 알아보겠습니다.