Browse Source

add translation kr/threejs-rendering-on-demand.md
add translation kr/threejs-transparency.md
update translation kr/threejs-prerequisites.md
add translation kr/threejs-multiple-scenes.md
add translation kr/threejs-picking.md
fix typo & add translation kr/threejs-post-processing.md
add translation kr/threejs-post-processing-3dlut.md

SeemsPyo 5 years ago
parent
commit
633dbb61bd

+ 696 - 0
threejs/lessons/kr/threejs-multiple-scenes.md

@@ -0,0 +1,696 @@
+Title: Three.js로 캔버스, 장면 여러 개 만들기
+Description: THREE.js로 다수의 장면을 렌더링해봅니다
+TOC: 다중 캔버스, 다중 장면 만들기
+
+사람들이 자주 하는 질문 중 하나는 Three.js로 여러 개의 캔버스(canvas)를 렌더링하려면
+어떻게 해야 하나요?"입니다. 쇼핑몰 사이트나 3D 도표가 여러 개 있는 웹 페이지를
+제작한다고 해봅시다. 얼핏 그리 어려울 건 없어 보입니다. 그냥 도표가 들어갈 곳마다
+각각 캔버스를 만들고, 각 캔버스마다 `Renderer`를 생성하면 되지 않을까요?
+
+하지만 이 방법을 적용하자마자 문제가 생깁니다.
+
+1. 브라우저의 WebGL 컨텍스트(context)는 제한적이다.
+
+    일반적으로 약 8개가 최대입니다. 9번째 컨텍스트를 만들면 제일 처음에 만들었던
+    컨텍스트가 사라지죠.
+
+2. WebGL 자원은 컨텍스트끼리 공유할 수 없다.
+
+    10MB짜리 모델을 캔버스 두 개에서 사용하려면 모델을 각각 총 두 번 로드해야 하고,
+    원래의 두 배인 20MB의 자원을 사용한다는 의미입니다. 컨텍스트끼리는 어떤 것도 공유할
+    수 없죠. 또한 초기화도 두 번, 쉐이더 컴파일도 두 번, 같은 동작은 모두 두 번씩
+    실행해야 합니다. 캔버스의 개수가 많아질수록 성능에 문제가 생기겠죠.
+
+그렇다면 어떻게 해야 할까요?
+
+방법 중 하나는 캔버스 하나로 화면 전체를 채우고, 각 "가상" 캔버스를 대신할 HTML 요소(element)를
+두는 겁니다. `Renderer`는 하나만 만들되 가상 캔버스에 각각 `Scene`을 만드는 거죠. 그리고
+가상 HTML 요소의 좌표를 계산해 요소가 화면에 보인다면 Three.js가 해당 장면(scene)을 가상
+요소의 좌표에 맞춰 렌더링하도록 합니다.
+
+이 방법은 캔버스를 하나만 사용하므로 위 1번과 2번 문제 모두 해결할 수 있습니다. 컨텍스트를
+하나만 사용하니 WebGL 컨텍스트 제한을 걱정할 일도 없고, 자원을 몇 배씩 더 사용할 일도 없죠.
+
+2개의 장면만 만들어 간단히 테스트를 해보겠습니다. 먼저 HTML을 작성합니다.
+
+```html
+<canvas id="c"></canvas>
+<p>
+  <span id="box" class="diagram left"></span>
+  I love boxes. Presents come in boxes.
+  When I find a new box I'm always excited to find out what's inside.
+</p>
+<p>
+  <span id="pyramid" class="diagram right"></span>
+  When I was a kid I dreamed of going on an expedition inside a pyramid
+  and finding a undiscovered tomb full of mummies and treasure.
+</p>
+```
+
+다음으로 CSS를 작성합니다.
+
+```css
+#c {
+  position: fixed;
+  left: 0;
+  top: 0;
+  width: 100vw;
+  height: 100vh;
+  display: block;
+  z-index: -1;
+}
+.diagram {
+  display: inline-block;
+  width: 5em;
+  height: 3em;
+  border: 1px solid black;
+}
+.left {
+  float: left;
+  margin-right: .25em;
+}
+.right {
+  float: right;
+  margin-left: .25em;
+}
+```
+
+캔버스가 화면 전체를 채우도록 하고 `z-index`를 -1로 설정해 다른 요소 뒤로 가도록 했습니다.
+가상 요소에 컨텐츠가 없어 크기가 0이니 별도의 width와 height도 지정해줬습니다.
+
+이제 각각의 카메라와 조명이 있는 장면 2개를 만듭니다. 하나에는 정육면체, 다른 하나에는
+다이아몬드 모양을 넣을 겁니다.
+
+```js
+function makeScene(elem) {
+  const scene = new THREE.Scene();
+
+  const fov = 45;
+  const aspect = 2;  // 캔버스 기본값
+  const near = 0.1;
+  const far = 5;
+  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
+  camera.position.z = 2;
+  camera.position.set(0, 1, 2);
+  camera.lookAt(0, 0, 0);
+
+  {
+    const color = 0xFFFFFF;
+    const intensity = 1;
+    const light = new THREE.DirectionalLight(color, intensity);
+    light.position.set(-1, 2, 4);
+    scene.add(light);
+  }
+
+  return { scene, camera, elem };
+}
+
+function setupScene1() {
+  const sceneInfo = makeScene(document.querySelector('#box'));
+  const geometry = new THREE.BoxBufferGeometry(1, 1, 1);
+  const material = new THREE.MeshPhongMaterial({color: 'red'});
+  const mesh = new THREE.Mesh(geometry, material);
+  sceneInfo.scene.add(mesh);
+  sceneInfo.mesh = mesh;
+  return sceneInfo;
+}
+
+function setupScene2() {
+  const sceneInfo = makeScene(document.querySelector('#pyramid'));
+  const radius = .8;
+  const widthSegments = 4;
+  const heightSegments = 2;
+  const geometry = new THREE.SphereBufferGeometry(radius, widthSegments, heightSegments);
+  const material = new THREE.MeshPhongMaterial({
+    color: 'blue',
+    flatShading: true,
+  });
+  const mesh = new THREE.Mesh(geometry, material);
+  sceneInfo.scene.add(mesh);
+  sceneInfo.mesh = mesh;
+  return sceneInfo;
+}
+
+const sceneInfo1 = setupScene1();
+const sceneInfo2 = setupScene2();
+```
+
+이제 각 요소가 화면에 보일 때만 장면을 렌더링할 함수를 만듭니다. `Renderer.setScissorTest`를
+호출해 *가위(scissor)* 테스트를 활성화하면 Three.js가 캔버스의 특정 부분만 렌더링하도록
+할 수 있습니다. 그리고 `Renderer.setScissor`로 가위를 설정한 뒤 `Renderer.setViewport`로
+장면의 좌표를 설정합니다.
+
+```js
+function renderSceneInfo(sceneInfo) {
+  const { scene, camera, elem } = sceneInfo;
+
+  // 해당 요소의 화면 대비 좌표를 가져옵니다
+  const { left, right, top, bottom, width, height } =
+      elem.getBoundingClientRect();
+
+  const isOffscreen =
+      bottom < 0 ||
+      top > renderer.domElement.clientHeight ||
+      right < 0 ||
+      left > renderer.domElement.clientWidth;
+
+  if (isOffscreen) {
+    return;
+  }
+
+  camera.aspect = width / height;
+  camera.updateProjectionMatrix();
+
+  const positiveYUpBottom = canvasRect.height - bottom;
+  renderer.setScissor(left, positiveYUpBottom, width, height);
+  renderer.setViewport(left, positiveYUpBottom, width, height);
+
+  renderer.render(scene, camera);
+}
+```
+
+다음으로 `render` 함수 안에서 먼저 캔버스 전체를 비운 뒤 각 장면을 렌더링합니다.
+
+```js
+function render(time) {
+  time *= 0.001;
+
+  resizeRendererToDisplaySize(renderer);
+
+  renderer.setScissorTest(false);
+  renderer.clear(true, true);
+  renderer.setScissorTest(true);
+
+  sceneInfo1.mesh.rotation.y = time * .1;
+  sceneInfo2.mesh.rotation.y = time * .1;
+
+  renderSceneInfo(sceneInfo1);
+  renderSceneInfo(sceneInfo2);
+
+  requestAnimationFrame(render);
+}
+```
+
+결과를 확인해볼까요?
+
+{{{example url="../threejs-multiple-scenes-v1.html" }}}
+
+첫 번째 `<span>` 요소가 있는 곳에는 빨간 정육면체가, 두 번째 `<span>` 요소가 있는 곳에는
+파란 다이아몬드가 보일 겁니다.
+
+## 동기화하기
+
+위 코드는 나쁘지 않지만 작은 문제가 있습니다. 복잡한 장면 등 무슨 이유라도 렌더링하는
+데 시간이 오래 걸린다면, 장면의 좌표는 페이지의 다른 컨텐츠에 비해 더디게 내려올 겁니다.
+
+각 가상 요소에 테두리를 넣고
+
+```css
+.diagram {
+  display: inline-block;
+  width: 5em;
+  height: 3em;
++  border: 1px solid black;
+}
+```
+
+각 장면에 배경색도 넣어줍니다.
+
+```js
+const scene = new THREE.Scene();
++scene.background = new THREE.Color('red');
+```
+
+그런 다음 <a href="../threejs-multiple-scenes-v2.html" target="_blank">빠르게 스크롤을 위아래로 반복해보면</a>
+문제가 보일겁니다. 아래는 스크롤 애니메이션 캡쳐본의 속도를 10배 낮춘 예시입니다.
+
+<div class="threejs_center"><img class="border" src="resources/images/multi-view-skew.gif"></div>
+
+추가로 처리해줘야 할 것이 있긴 하지만, 캔버스의 CSS를 `position: fixed`에서 `position: absolute`로
+바꿔 문제를 해결할 수 있습니다.
+
+```css
+#c {
+-  position: fixed;
++  position: absolute;
+```
+
+그리고 페이지 스크롤에 상관 없이 캔버스가 항상 화면의 상단에 위치할 수 있도록 캔버스에
+transform 스타일을 지정해줍니다.
+
+```js
+function render(time) {
+  ...
+
+  const transform = `translateY(${ window.scrollY }px)`;
+  renderer.domElement.style.transform = transform;
+
+```
+
+캔버스에 `position: fixed`를 적용하면 캔버스는 스크롤의 영향을 받지 않습니다. `position: absolute`를
+적용하면 렌더링하는 데 시간이 걸리더라도 일단 다른 페이지와 같이 스크롤이 되겠죠. 그리고
+렌더링하기 전에 캔버스를 다시 움직여 화면 전체에 맞춘 뒤 캔버스를 렌더링하는 겁니다. 이러면
+화면의 가장자리에 살짝 렌더링되지 않은 부분이 보일 수는 있어도 나머지 페이지에 있는 요소는
+버벅이지 않고 제자리에 있을 겁니다. 아래는 해당 코드를 적용한 화면의 캡쳐본을 아까와 마찬가지로
+10배 느리게 만든 것입니다.
+
+<div class="threejs_center"><img class="border" src="resources/images/multi-view-fixed.gif"></div>
+
+## 확장하기 쉽게 만들기
+
+여러 장면을 구현했으니 이제 이 예제를 좀 더 확장하기 쉽게 만들어보겠습니다.
+
+먼저 기존처럼 캔버스 전체를 렌더링하는 `render` 함수를 두고, 각 장면에 해당하는 가상 요소,
+해당 장면을 렌더링하는 함수로 이루어진 객체의 배열을 만듭니다. `render` 함수에서 가상 요소가
+화면에 보이는지 확인한 뒤, 가상 요소가 화면에 보인다면 상응하는 렌더링 함수를 호출합니다. 이러면
+확장성은 물론 각 장면의 렌더링 함수를 작성할 때도 전체를 신경쓸 필요가 없죠.
+
+아래는 전체를 담당하는 `render` 함수입니다.
+
+```js
+const sceneElements = [];
+function addScene(elem, fn) {
+  sceneElements.push({ elem, fn });
+}
+
+function render(time) {
+  time *= 0.001;
+
+  resizeRendererToDisplaySize(renderer);
+
+  renderer.setScissorTest(false);
+  renderer.setClearColor(clearColor, 0);
+  renderer.clear(true, true);
+  renderer.setScissorTest(true);
+
+  const transform = `translateY(${ window.scrollY }px)`;
+  renderer.domElement.style.transform = transform;
+
+  for (const { elem, fn } of sceneElements) {
+    // 해당 요소의 화면 대비 좌표를 가져옵니다
+    const rect = elem.getBoundingClientRect();
+    const {left, right, top, bottom, width, height} = rect;
+
+    const isOffscreen =
+        bottom < 0 ||
+        top > renderer.domElement.clientHeight ||
+        right < 0 ||
+        left > renderer.domElement.clientWidth;
+
+    if (!isOffscreen) {
+      const positiveYUpBottom = renderer.domElement.clientHeight - bottom;
+      renderer.setScissor(left, positiveYUpBottom, width, height);
+      renderer.setViewport(left, positiveYUpBottom, width, height);
+
+      fn(time, rect);
+    }
+  }
+
+  requestAnimationFrame(render);
+}
+```
+
+`render` 함수는 `elem`과 `fn` 속성의 객체로 이루어진 `sceneElements` 배열을 순회합니다.
+
+그리고 각 요소가 화면에 보이는지 확인하고, 화면에 보인다면 `fn`에 해당 장면이 들어가야할
+사각 좌표와 현재 시간값을 넘겨주어 호출합니다.
+
+이제 각 장면을 만들고 상응하는 요소와 렌더링 함수를 추가합니다.
+
+```js
+{
+  const elem = document.querySelector('#box');
+  const { scene, camera } = makeScene();
+  const geometry = new THREE.BoxBufferGeometry(1, 1, 1);
+  const material = new THREE.MeshPhongMaterial({ color: 'red' });
+  const mesh = new THREE.Mesh(geometry, material);
+  scene.add(mesh);
+  addScene(elem, (time, rect) => {
+    camera.aspect = rect.width / rect.height;
+    camera.updateProjectionMatrix();
+    mesh.rotation.y = time * .1;
+    renderer.render(scene, camera);
+  });
+}
+
+{
+  const elem = document.querySelector('#pyramid');
+  const { scene, camera } = makeScene();
+  const radius = .8;
+  const widthSegments = 4;
+  const heightSegments = 2;
+  const geometry = new THREE.SphereBufferGeometry(radius, widthSegments, heightSegments);
+  const material = new THREE.MeshPhongMaterial({
+    color: 'blue',
+    flatShading: true,
+  });
+  const mesh = new THREE.Mesh(geometry, material);
+  scene.add(mesh);
+  addScene(elem, (time, rect) => {
+    camera.aspect = rect.width / rect.height;
+    camera.updateProjectionMatrix();
+    mesh.rotation.y = time * .1;
+    renderer.render(scene, camera);
+  });
+}
+```
+
+`sceneInfo1`, `sceneInfo2`는 더 이상 필요 없으니 제거합니다. 대신 각 mesh의 회전은 해당
+장면에서 처리해야 합니다.
+
+{{{example url="../threejs-multiple-scenes-generic.html" }}}
+
+## HTML Dataset 사용하기
+
+HTML의 [dataset](https://developer.mozilla.org/ko/docs/Web/API/HTMLElement/dataset)을
+이용하면 좀 더 확장하기 쉬운 환경을 만들 수 있습니다. `id="..."` 대신 `data-diagram="..."`을
+이용해 데이터를 직접 HTML 요소에 지정하는 거죠.
+
+```html
+<canvas id="c"></canvas>
+<p>
+-  <span id="box" class="diagram left"></span>
++  <span data-diagram="box" class="left"></span>
+  I love boxes. Presents come in boxes.
+  When I find a new box I'm always excited to find out what's inside.
+</p>
+<p>
+-  <span id="pyramid" class="diagram left"></span>
++  <span data-diagram="pyramid" class="right"></span>
+  When I was a kid I dreamed of going on an expedition inside a pyramid
+  and finding a undiscovered tomb full of mummies and treasure.
+</p>
+```
+
+요소의 id를 제거했으니 CSS 셀렉터도 다음처럼 바꾸어야 합니다.
+
+```css
+-.diagram
++*[data-diagram] {
+  display: inline-block;
+  width: 5em;
+  height: 3em;
+}
+```
+
+또한 각 장면을 만드는 코드를 *scene initialization functions*라는 맵으로 만듭니다.
+이 맵은 키값에 대응하는 *장면 렌더링 함수*를 반환할 겁니다.
+
+```js
+const sceneInitFunctionsByName = {
+  'box': () => {
+    const { scene, camera } = makeScene();
+    const geometry = new THREE.BoxBufferGeometry(1, 1, 1);
+    const material = new THREE.MeshPhongMaterial({color: 'red'});
+    const mesh = new THREE.Mesh(geometry, material);
+    scene.add(mesh);
+    return (time, rect) => {
+      mesh.rotation.y = time * .1;
+      camera.aspect = rect.width / rect.height;
+      camera.updateProjectionMatrix();
+      renderer.render(scene, camera);
+    };
+  },
+  'pyramid': () => {
+    const { scene, camera } = makeScene();
+    const radius = .8;
+    const widthSegments = 4;
+    const heightSegments = 2;
+    const geometry = new THREE.SphereBufferGeometry(radius, widthSegments, heightSegments);
+    const material = new THREE.MeshPhongMaterial({
+      color: 'blue',
+      flatShading: true,
+    });
+    const mesh = new THREE.Mesh(geometry, material);
+    scene.add(mesh);
+    return (time, rect) => {
+      mesh.rotation.y = time * .1;
+      camera.aspect = rect.width / rect.height;
+      camera.updateProjectionMatrix();
+      renderer.render(scene, camera);
+    };
+  },
+};
+```
+
+그리고 `querySelectorAll`로 가상 요소를 전부 불러와 해당 요소에 상응하는 렌더링 함수를
+실행합니다.
+
+```js
+document.querySelectorAll('[data-diagram]').forEach((elem) => {
+  const sceneName = elem.dataset.diagram;
+  const sceneInitFunction = sceneInitFunctionsByName[sceneName];
+  const sceneRenderFunction = sceneInitFunction(elem);
+  addScene(elem, sceneRenderFunction);
+});
+```
+
+이제 코드를 확장하기가 한결 편해졌습니다.
+
+{{{examples url="../threejs-multiple-scenes-selector.html" }}}
+
+## 각 요소에 액션 추가하기
+
+사용자 액션, 예를 들어 `TrackballControls`를 추가하는 건 아주 간단합니다. 먼저 스크립트를
+불러옵니다.
+
+```js
+import { TrackballControls } from './resources/threejs/r115/examples/jsm/controls/TrackballControls.js';
+```
+
+그리고 각 장면에 대응하는 요소에 `TrackballControls`를 추가합니다.
+
+```js
+-function makeScene() {
++function makeScene(elem) {
+  const scene = new THREE.Scene();
+
+  const fov = 45;
+  const aspect = 2;  // 캔버스 기본값
+  const near = 0.1;
+  const far = 5;
+  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
+  camera.position.set(0, 1, 2);
+  camera.lookAt(0, 0, 0);
++  scene.add(camera);
+
++  const controls = new TrackballControls(camera, elem);
++  controls.noZoom = true;
++  controls.noPan = true;
+
+  {
+    const color = 0xFFFFFF;
+    const intensity = 1;
+    const light = new THREE.DirectionalLight(color, intensity);
+    light.position.set(-1, 2, 4);
+-    scene.add(light);
++    camera.add(light);
+  }
+
+-  return { scene, camera };
++ return { scene, camera, controls };
+}
+```
+
+위 코드에서는 카메라를 장면에 추가하고, 카메라에 조명을 추가했습니다. 이러면 조명이 카메라를
+따라다니겠죠. `TrackballControls`는 카메라를 조정하기 때문에 이렇게 해야 빛이 계속 우리가
+바라보는 방향에서 나갑니다.
+
+또한 컨트롤을 렌더링 함수에서 업데이트해줘야 합니다.
+
+```js
+const sceneInitFunctionsByName = {
+- 'box': () => {
+-    const {scene, camera} = makeScene();
++ 'box': (elem) => {
++    const { scene, camera, controls } = makeScene(elem);
+    const geometry = new THREE.BoxBufferGeometry(1, 1, 1);
+    const material = new THREE.MeshPhongMaterial({color: 'red'});
+    const mesh = new THREE.Mesh(geometry, material);
+    scene.add(mesh);
+    return (time, rect) => {
+      mesh.rotation.y = time * .1;
+      camera.aspect = rect.width / rect.height;
+      camera.updateProjectionMatrix();
++      controls.handleResize();
++      controls.update();
+      renderer.render(scene, camera);
+    };
+  },
+-  'pyramid': () => {
+-    const { scene, camera } = makeScene();
++  'pyramid': (elem) => {
++    const { scene, camera, controls } = makeScene(elem);
+    const radius = .8;
+    const widthSegments = 4;
+    const heightSegments = 2;
+    const geometry = new THREE.SphereBufferGeometry(radius, widthSegments, heightSegments);
+    const material = new THREE.MeshPhongMaterial({
+      color: 'blue',
+      flatShading: true,
+    });
+    const mesh = new THREE.Mesh(geometry, material);
+    scene.add(mesh);
+    return (time, rect) => {
+      mesh.rotation.y = time * .1;
+      camera.aspect = rect.width / rect.height;
+      camera.updateProjectionMatrix();
++      controls.handleResize();
++      controls.update();
+      renderer.render(scene, camera);
+    };
+  },
+};
+```
+
+이제 각 물체를 자유롭게 회전시킬 수 있습니다.
+
+{{{example url="../threejs-multiple-scenes-controls.html" }}}
+
+이 기법은 이 사이트 전체에 사용한 기법입니다. [원시 모델에 관한 글](threejs-primitives.html)과
+[재질에 관한 글](threejs-materials.html)에서 다양한 예시를 보여주기 위해 사용했죠.
+
+다른 방법으로는 화면 밖의 캔버스에서 장면을 렌더링해 각 요소에 2D 캔버스 형태로 넘겨주는
+방법이 있습니다. 이 방법의 장점은 각 영역을 어떻게 분리할지 고민하지 않아도 된다는 것이죠.
+위에서 살펴본 방법은 캔버스를 화면 전체의 배경으로 써야 하지만, 이 방법은 일반 HTML 형태로
+사용할 수 있습니다.
+
+하지만 이 방법은 각 영역을 복사하는 것이기에 성능이 더 느립니다. 얼마나 느릴지는 브라우저와
+GPU 성능에 따라 다르죠.
+
+바꿔야 하는 건 생각보다 많지 않습니다.
+
+먼저 배경에서 캔버스 요소를 제거합니다.
+
+```html
+<body>
+-  <canvas id="c"></canvas>
+  ...
+</body>
+```
+
+CSS도 바꿔줍니다.
+
+```css
+-#c {
+-  position: absolute;
+-  left: 0;
+-  top: 0;
+-  width: 100vw;
+-  height: 100vh;
+-  display: block;
+-  z-index: -1;
+-}
+canvas {
+  width: 100%;
+  height: 100%;
+  display: block;
+}
+*[data-diagram] {
+  display: inline-block;
+  width: 5em;
+  height: 3em;
+}
+```
+
+캔버스 요소가 부모에 꽉 차도록 변경했습니다.
+
+이제 자바스크립트를 변경해봅시다. 먼저 캔버스를 참조할 필요가 없으니 대신 캔버스 요소를
+새로 만듭니다. 또한 가위 테스트를 처음에 활성화합니다.
+
+```js
+function main() {
+-  const canvas = document.querySelector('#c');
++  const canvas = document.createElement('canvas');
+  const renderer = new THREE.WebGLRenderer({canvas, alpha: true});
++  renderer.setScissorTest(true);
+
+  ...
+```
+
+다음으로 각 장면에 2D 렌더링 컨텍스트를 생성하고 장면에 대응하는 요소에 캔버스를 추가합니다.
+
+```js
+const sceneElements = [];
+function addScene(elem, fn) {
++  const ctx = document.createElement('canvas').getContext('2d');
++  elem.appendChild(ctx.canvas);
+-  sceneElements.push({ elem, fn });
++  sceneElements.push({ elem, ctx, fn });
+}
+```
+
+만약 렌더링 시 렌더링용 캔버스의 크기가 장면의 크기보다 작을 경우, 렌더링용 캔버스의 크기를
+키웁니다. 또한 2D 캔버스의 크기가 부모 요소와 다르다면 2D 캔버스의 크기를 조정합니다. 마지막으로
+가위와 화면을 설정하고, 해당 장면을 렌더링한 뒤, 요소의 캔버스로 렌더링 결과물을 복사합니다.
+
+```js
+function render(time) {
+  time *= 0.001;
+
+-  resizeRendererToDisplaySize(renderer);
+-
+-  renderer.setScissorTest(false);
+-  renderer.setClearColor(clearColor, 0);
+-  renderer.clear(true, true);
+-  renderer.setScissorTest(true);
+-
+-  const transform = `translateY(${ window.scrollY }px)`;
+-  renderer.domElement.style.transform = transform;
+
+-  for (const { elem, fn } of sceneElements) {
++  for (const { elem, fn, ctx } of sceneElements) {
+    // 해당 요소의 화면 대비 좌표를 가져옵니다
+    const rect = elem.getBoundingClientRect();
+    const { left, right, top, bottom, width, height } = rect;
++    const rendererCanvas = renderer.domElement;
+
+    const isOffscreen =
+        bottom < 0 ||
+-        top > renderer.domElement.clientHeight ||
++        top > window.innerHeight ||
+        right < 0 ||
+-        left > renderer.domElement.clientWidth;
++        left > window.innerWidth;
+
+    if (!isOffscreen) {
+-      const positiveYUpBottom = renderer.domElement.clientHeight - bottom;
+-      renderer.setScissor(left, positiveYUpBottom, width, height);
+-      renderer.setViewport(left, positiveYUpBottom, width, height);
+
++      // 렌더링용 캔버스 크기 조정
++      if (rendererCanvas.width < width || rendererCanvas.height < height) {
++        renderer.setSize(width, height, false);
++      }
++
++      // 2D 캔버스의 크기가 요소의 크기와 같도록 조정
++      if (ctx.canvas.width !== width || ctx.canvas.height !== height) {
++        ctx.canvas.width = width;
++        ctx.canvas.height = height;
++      }
++
++      renderer.setScissor(0, 0, width, height);
++      renderer.setViewport(0, 0, width, height);
+
+      fn(time, rect);
+
++      // 렌더링된 장면을 2D 캔버스에 복사
++      ctx.globalCompositeOperation = 'copy';
++      ctx.drawImage(
++          rendererCanvas,
++          0, rendererCanvas.height - height, width, height,  // 원본 사각 좌표
++          0, 0, width, height);                              // 결과물 사각 좌표
+    }
+  }
+
+  requestAnimationFrame(render);
+}
+```
+
+결과물은 위와 다르지 않습니다.
+
+{{{example url="../threejs-multiple-scenes-copy-canvas.html" }}}
+
+이 기법의 다른 장점은 [`OffscreenCanvas`](https://developer.mozilla.org/ko/docs/Web/API/OffscreenCanvas)
+웹 워커를 이용해 이 기능을 별도 스레드에서 구현할 수 있다는 겁니다. 하지만 아쉽게도
+2020년 7월을 기준으로 `OffscreenCanvas`는 아직 크로미움 기반 브라우저에서만 지원합니다.

+ 417 - 0
threejs/lessons/kr/threejs-picking.md

@@ -0,0 +1,417 @@
+Title: Three.js 피킹(Picking)
+Description: Three.js에서 마우스로 요소를 선택하는 법을 알아봅니다
+TOC: 물체를 마우스로 피킹하기
+
+*피킹(picking)*이란 사용자가 클릭 또는 터치한 물체를 가려내는 작업을 말합니다. 피킹을 구현하는 방법은 수없이 많지만, 각자 단점이 있습니다. 이 글에서는 이 방법 중 흔히 사용하는 2가지 방법만 살펴보겠습니다.
+
+아마 *피킹*을 구현하는 가장 흔한 방법은 광선 투사(ray casting)일 겁니다. 광선 투사란 포인터(커서)에서 장면의 절두체로 광선을 쏴 광선이 닿는 물체를 감지하는 기법을 말하죠. 이론적으로 가장 간단한 방법입니다.
+
+먼저 포인터의 좌표를 구한 뒤, 이 좌표를 카메라의 시선과 방향에 따라 3D 좌표로 변환합니다. 그리고 near 면에서 far 면까지의 광선을 구해 이 광선이 장면 안 각 물체의 삼각형과 교차하는지 확인합니다. 만약 장면 안에 1000개의 삼각형을 가진 물체가 1000개 있다면 백만 개의 삼각형을 일일이 확인해야 하는 셈이죠.
+
+이를 최적화하려면 몇 가지 방법을 시도해볼 수 있습니다. 하나는 먼저 물체를 감싼 경계(bounding) 좌표가 광선과 교차하는지 확인하고, 교차하지 않는다면 해당 물체의 삼각형을 확인하지 않는 것이죠.
+
+Three.js에는 이런 작업을 대신해주는 `RayCaster` 클래스가 있습니다.
+
+한번 물체 100개가 있는 장면을 만들어 여기서 피킹을 구현해봅시다. 예제는 [반응형 디자인](threejs-responsive.html)에서 썼던 예제를 가져와 사용하겠습니다.
+
+우선 카메라를 별도 `Object3D`의 자식으로 추가해 카메라가 셀카봉처럼 장면 주위를 돌 수 있도록 합니다.
+
+```js
+*const fov = 60;
+const aspect = 2;  // 캔버스 기본값
+const near = 0.1;
+*const far = 200;
+const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
+*camera.position.z = 30;
+
+const scene = new THREE.Scene();
++scene.background = new THREE.Color('white');
+
++// 카메라를 봉(pole)에 추가합니다.
++// 이러면 봉을 회전시켜 카메라가 장면 주위를 돌도록 할 수 있습니다
++const cameraPole = new THREE.Object3D();
++scene.add(cameraPole);
++cameraPole.add(camera);
+```
+
+그리고 `render` 함수 안에서 카메라 봉을 돌립니다.
+
+```js
+cameraPole.rotation.y = time * .1;
+```
+
+또한 카메라에 조명을 추가해 조명이 카메라와 같이 움직이도록 합니다.
+
+```js
+-scene.add(light);
++camera.add(light);
+```
+
+정육면체 100개의 위치, 방향, 크기를 무작위로 설정해 생성합니다.
+
+```js
+const boxWidth = 1;
+const boxHeight = 1;
+const boxDepth = 1;
+const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
+
+function rand(min, max) {
+  if (max === undefined) {
+    max = min;
+    min = 0;
+  }
+  return min + (max - min) * Math.random();
+}
+
+function randomColor() {
+  return `hsl(${ rand(360) | 0 }, ${ rand(50, 100) | 0 }%, 50%)`;
+}
+
+const numObjects = 100;
+for (let i = 0; i < numObjects; ++i) {
+  const material = new THREE.MeshPhongMaterial({
+    color: randomColor(),
+  });
+
+  const cube = new THREE.Mesh(geometry, material);
+  scene.add(cube);
+
+  cube.position.set(rand(-20, 20), rand(-20, 20), rand(-20, 20));
+  cube.rotation.set(rand(Math.PI), rand(Math.PI), 0);
+  cube.scale.set(rand(3, 6), rand(3, 6), rand(3, 6));
+}
+```
+
+이제 피킹을 구현해봅시다.
+
+피킹을 관리할 간단한 클래스를 만들겠습니다.
+
+```js
+class PickHelper {
+  constructor() {
+    this.raycaster = new THREE.Raycaster();
+    this.pickedObject = null;
+    this.pickedObjectSavedColor = 0;
+  }
+  pick(normalizedPosition, scene, camera, time) {
+    // 이미 다른 물체를 피킹했다면 색을 복원합니다
+    if (this.pickedObject) {
+      this.pickedObject.material.emissive.setHex(this.pickedObjectSavedColor);
+      this.pickedObject = undefined;
+    }
+
+    // 절두체 안에 광선을 쏩니다
+    this.raycaster.setFromCamera(normalizedPosition, camera);
+    // 광선과 교차하는 물체들을 배열로 만듭니다
+    const intersectedObjects = this.raycaster.intersectObjects(scene.children);
+    if (intersectedObjects.length) {
+      // 첫 번째 물체가 제일 가까우므로 해당 물체를 고릅니다
+      this.pickedObject = intersectedObjects[0].object;
+      // 기존 색을 저장해둡니다
+      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
+      // emissive 색을 빨강/노랑으로 빛나게 만듭니다
+      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
+    }
+  }
+}
+```
+
+위 클래스는 먼저 `RayCaster` 인스턴스를 만들고 `pick` 메서드를 호출하면 장면에 광선을 쏠 수 있게 해줍니다. 그리고 광선에 맞는 요소가 있으면 해당 요소 중 가장 첫 번째 요소의 색을 변경합니다.
+
+사용자가 마우스를 눌렀을 때(down)만 이 함수가 작동하도록 할 수도 있지만, 예제에서는 마우스 포인터 아래의 있는 요소를 피킹하도록 하겠습니다. 이를 구현하려면 먼저 포인터를 추적해야 합니다.
+
+```js
+const pickPosition = { x: 0, y: 0 };
+clearPickPosition();
+
+...
+
+function getCanvasRelativePosition(event) {
+  const rect = canvas.getBoundingClientRect();
+  return {
+    x: (event.clientX - rect.left) * canvas.width  / rect.width,
+    y: (event.clientY - rect.top ) * canvas.height / rect.height,
+  };
+}
+
+function setPickPosition(event) {
+  const pos = getCanvasRelativePosition(event);
+  pickPosition.x = (pos.x / canvas.width ) *  2 - 1;
+  pickPosition.y = (pos.y / canvas.height) * -2 + 1;  // Y 축을 뒤집었음
+}
+
+function clearPickPosition() {
+  /**
+   * 마우스의 경우는 항상 위치가 있어 그다지 큰
+   * 상관이 없지만, 터치 같은 경우 사용자가 손가락을
+   * 떼면 피킹을 멈춰야 합니다. 지금은 일단 어떤 것도
+   * 선택할 수 없는 값으로 지정해두었습니다
+   **/
+  pickPosition.x = -100000;
+  pickPosition.y = -100000;
+}
+
+window.addEventListener('mousemove', setPickPosition);
+window.addEventListener('mouseout', clearPickPosition);
+window.addEventListener('mouseleave', clearPickPosition);
+```
+
+위 예제에서는 마우스의 좌표를 정규화(normalize)했습니다. 캔버스의 크기와 상관없이 왼쪽 끝이 -1, 오른쪽 끝이 +1인 벡터값이 필요하기 때문이죠. 마찬가지로 아래쪽 끝은 -1, 위쪽 끝은 +1입니다.
+
+모바일도 환경도 지원하기 위해 리스너를 더 추가하겠습니다.
+
+```js
+window.addEventListener('touchstart', (event) => {
+  event.preventDefault(); // 스크롤 이벤트 방지
+  setPickPosition(event.touches[0]);
+}, { passive: false });
+
+window.addEventListener('touchmove', (event) => {
+  setPickPosition(event.touches[0]);
+});
+
+window.addEventListener('touchend', clearPickPosition);
+```
+
+마지막으로 `render` 함수에서 `PickHelper`의 `pick` 메서드를 호출합니다.
+
+```js
++const pickHelper = new PickHelper();
+
+function render(time) {
+  time *= 0.001;  // 초 단위로 변환
+
+  ...
+
++  pickHelper.pick(pickPosition, scene, camera, time);
+
+  renderer.render(scene, camera);
+
+  ...
+```
+
+결과를 볼까요?
+
+{{{example url="../threejs-picking-raycaster.html" }}}
+
+딱히 문제는 없어 보입니다. 실제로 사용하는 경우도 대부분 문제 없이 잘 되겠지만, 이 방법에는 몇 가지 문제점이 있습니다.
+
+1. CPU의 자원을 사용한다
+
+    자바스크립트 엔진은 각 요소를 돌며 광선이 요소의 경계 좌표 안에 교차하는지 확인합니다. 만약 교차할 경우, 해당 요소의 삼각형을 전부 돌며 광선과 교차하는 삼각형이 있는지 확인합니다.
+    
+    이 방식의 장점은 자바스크립트가 교차하는 지점을 정확히 계산해 해당 데이터를 넘겨줄 수 있다는 점입니다. 예를 들어 교차가 발생한 지점에 특정 표시를 할 수 있겠죠.
+
+    대신 CPU가 할 일이 더 늘어난다는 점이 단점입니다. 요소가 가진 삼각형이 많을수록 더 느려지겠죠.
+
+2. 특이한 방식의 쉐이더나 변이를 감지하지 못한다
+
+    만약 장면에서 geometry를 변형하는 쉐이더를 사용한다면, 자바스크립트는 이 변형을 감지하지 못하기에 잘못된 값을 내놓을 겁니다. 제가 테스트해본 결과 스킨이 적용된 요소에는 이 방법이 먹히지 않습니다.
+
+3. 요소의 투명한 구멍을 처리하지 못한다.
+
+예제를 하나 만들어보죠. 아래와 같은 텍스처를 정육면체에 적용해봅시다.
+
+<div class="threejs_center"><img class="checkerboard" src="../resources/images/frame.png"></div>
+
+그다지 추가할 건 많지 않습니다.
+
+```js
++const loader = new THREE.TextureLoader();
++const texture = loader.load('resources/images/frame.png');
+
+const numObjects = 100;
+for (let i = 0; i < numObjects; ++i) {
+  const material = new THREE.MeshPhongMaterial({
+    color: randomColor(),
+    +map: texture,
+    +transparent: true,
+    +side: THREE.DoubleSide,
+    +alphaTest: 0.1,
+  });
+
+  const cube = new THREE.Mesh(geometry, material);
+  scene.add(cube);
+
+  ...
+```
+
+예제를 실행시키면 바로 문제가 보일 겁니다.
+
+{{{example url="../threejs-picking-raycaster-transparency.html" }}}
+
+정육면체의 빈 공간을 통해 무언가를 선택할 수가 없죠.
+
+<div class="threejs_center"><img src="resources/images/picking-transparent-issue.jpg" style="width: 635px;"></div>
+
+이는 자바스크립트가 텍스처나 재질을 보고 해당 요소가 투명한지 판단하기가 어렵기 때문입니다.
+
+이 문제를 해결하려면 GPU 기반 피킹을 구현해야 합니다. 이론적으로는 간단하지만 위에서 사용한 광선 투사법보다는 좀 더 복잡하죠.
+
+GPU 피킹을 구현하려면 각 요소를 별도의 화면에서 고유한 색상으로 렌더링해야 합니다. 그리고 포인터 아래에 있는 픽셀의 색을 가져와 해당 요소가 선택됐는지 확인하는 거죠.
+
+이러면 위에서 언급한 문제점 2, 3번이 해결됩니다. 1번, 성능의 경우는 상황에 따라 천차만별이죠. 눈에 보이는 화면을 위해 한 번, 피킹을 위해 한 번, 이렇게 매 요소를 총 두 번씩 렌더링해야 합니다. 더 복잡한 해결책을 쓰면 렌더링을 한 번만 할 수도 있지만, 이 글에서는 일단 더 간단한 방법을 사용하겠습니다.
+
+성능 최적화를 위해 시도할 수 있는 방법이 하나 있습니다. 어차피 픽셀을 하나만 읽을 것이니, 카메라를 픽셀 하나만 렌더링하도록 설정하는 것이죠. `PerspectiveCamera.setViewOffset` 메서드를 사용하면 카메라의 특정 부분만 렌더링하도록 할 수 있습니다. 이러면 성능 향상에 조금이나마 도움이 되겠죠.
+
+현재 Three.js에서 이 기법을 구현하려면 장면 2개를 사용해야 합니다. 하나는 기존 mesh를 그대로 쓰고, 나머지 하나는 피킹용 재질을 적용한 mesh를 쓸 겁니다.
+
+먼저 두 번째 장면을 추가하고 배경을 검정으로 지정합니다.
+
+```js
+const scene = new THREE.Scene();
+scene.background = new THREE.Color('white');
+const pickingScene = new THREE.Scene();
+pickingScene.background = new THREE.Color(0);
+```
+
+각 정육면체를 장면에 추가할 때 `pickingScene`의 같은 위치에 "피킹용 정육면체"를 추가합니다. 그리고 각 피킹용 정육면체에는 id로 쓸 고유 색상값을 지정한 뒤, 이 id 색상값으로 재질을 만들어 추가합니다. id 색상값을 정육면체의 키값으로 매핑해 놓으면 나중에 상응하는 정육면체를 바로 불러올 수 있겠죠.
+
+```js
+const idToObject = {};
++const numObjects = 100;
+for (let i = 0; i < numObjects; ++i) {
++  const id = i + 1;
+  const material = new THREE.MeshPhongMaterial({
+    color: randomColor(),
+    map: texture,
+    transparent: true,
+    side: THREE.DoubleSide,
+    alphaTest: 0.1,
+  });
+
+  const cube = new THREE.Mesh(geometry, material);
+  scene.add(cube);
++  idToObject[id] = cube;
+
+  cube.position.set(rand(-20, 20), rand(-20, 20), rand(-20, 20));
+  cube.rotation.set(rand(Math.PI), rand(Math.PI), 0);
+  cube.scale.set(rand(3, 6), rand(3, 6), rand(3, 6));
+
++  const pickingMaterial = new THREE.MeshPhongMaterial({
++    emissive: new THREE.Color(id),
++    color: new THREE.Color(0, 0, 0),
++    specular: new THREE.Color(0, 0, 0),
++    map: texture,
++    transparent: true,
++    side: THREE.DoubleSide,
++    alphaTest: 0.5,
++    blending: THREE.NoBlending,
++  });
++  const pickingCube = new THREE.Mesh(geometry, pickingMaterial);
++  pickingScene.add(pickingCube);
++  pickingCube.position.copy(cube.position);
++  pickingCube.rotation.copy(cube.rotation);
++  pickingCube.scale.copy(cube.scale);
+}
+```
+
+위 코드에서는 `MeshPhongMaterial`로 편법을 사용했습니다. `emissive` 속성을 id 색상값으로, `color`와 `specular` 속성을 0으로 설정하면 텍스처의 알파값이 `alphaTest`보다 큰 부분만 id 색상값으로 보이겠죠. 또 `blending` 속성을 `THREE.NoBlending`으로 설정해 id 색상값이 알파값의 영향을 받지 않도록 했습니다.
+
+제가 사용한 편법이 최적의 해결책은 아닙니다. 여러가지 옵션을 껐다고 해도 여전히 조명 관련 연산을 실행할 테니까요. 코드를 더 최적화하려면 `alphaTest` 값보다 높은 경우에만 id 색상을 렌더링하는 쉐이더를 직접 만들어야 합니다.
+
+광선 투사법을 쓸 때와 달리 픽셀을 하나만 사용하므로 위치값이 픽셀 하나만 가리키게 변경합니다.
+
+```js
+function setPickPosition(event) {
+  const pos = getCanvasRelativePosition(event);
+-  pickPosition.x = (pos.x / canvas.clientWidth ) *  2 - 1;
+-  pickPosition.y = (pos.y / canvas.clientHeight) * -2 + 1;  // Y 축을 뒤집었음
++  pickPosition.x = pos.x;
++  pickPosition.y = pos.y;
+}
+```
+
+`PickHelper` 클래스도 `GPUPickHelper`로 변경합니다. [렌더 타겟(render target)에 관한 글](threejs-rendertargets.html)에서 다룬 `WebGLRenderTarget`을 써 구현하되, 이번 렌더 타겟의 크기는 1x1, 1픽셀입니다.
+
+```js
+-class PickHelper {
++class GPUPickHelper {
+  constructor() {
+-    this.raycaster = new THREE.Raycaster();
++    // 1x1 픽셀 크기의 렌더 타겟을 생성합니다
++    this.pickingTexture = new THREE.WebGLRenderTarget(1, 1);
++    this.pixelBuffer = new Uint8Array(4);
+    this.pickedObject = null;
+    this.pickedObjectSavedColor = 0;
+  }
+  pick(cssPosition, scene, camera, time) {
++    const {pickingTexture, pixelBuffer} = this;
+
+    // 기존에 선택된 요소가 있는 경우 색을 복원합니다
+    if (this.pickedObject) {
+      this.pickedObject.material.emissive.setHex(this.pickedObjectSavedColor);
+      this.pickedObject = undefined;
+    }
+
++    // view offset을 마우스 포인터 아래 1픽셀로 설정합니다
++    const pixelRatio = renderer.getPixelRatio();
++    camera.setViewOffset(
++        renderer.getContext().drawingBufferWidth,   // 전체 너비
++        renderer.getContext().drawingBufferHeight,  // 전체 높이
++        cssPosition.x * pixelRatio | 0,             // 사각 x 좌표
++        cssPosition.y * pixelRatio | 0,             // 사각 y 좌표
++        1,                                          // 사각 좌표 width
++        1,                                          // 사각 좌표 height
++    );
++    // 장면을 렌더링합니다
++    renderer.setRenderTarget(pickingTexture)
++    renderer.render(scene, camera);
++    renderer.setRenderTarget(null);
++
++    // view offset을 정상으로 돌려 원래의 화면을 렌더링하도록 합니다
++    camera.clearViewOffset();
++    // 픽셀을 감지합니다
++    renderer.readRenderTargetPixels(
++        pickingTexture,
++        0,   // x
++        0,   // y
++        1,   // width
++        1,   // height
++        pixelBuffer);
++
++    const id =
++        (pixelBuffer[0] << 16) |
++        (pixelBuffer[1] <<  8) |
++        (pixelBuffer[2]      );
+
+    // 절두체 안에 광선을 쏩니다
+-    this.raycaster.setFromCamera(normalizedPosition, camera);
+    // 광선과 교차하는 물체들을 배열로 만듭니다
+-    const intersectedObjects = this.raycaster.intersectObjects(scene.children);
+-    if (intersectedObjects.length) {
+      // 첫 번째 물체가 제일 가까우므로 해당 물체를 고릅니다
+-      this.pickedObject = intersectedObjects[0].object;
+
++    const intersectedObject = idToObject[id];
++    if (intersectedObject) {
+      // 첫 번째 물체가 제일 가까우므로 해당 물체를 고릅니다
++      this.pickedObject = intersectedObject;
+      // 기존 색을 저장해둡니다
+      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
+      // emissive 색을 빨강/노랑으로 빛나게 만듭니다
+      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
+    }
+  }
+}
+```
+
+인스턴스를 만드는 쪽도 수정합니다.
+
+```js
+-const pickHelper = new PickHelper();
++const pickHelper = new GPUPickHelper();
+```
+
+`pick` 메서드를 호출할 때 `scene` 대신 `pickScene`을 넘겨줍니다.
+
+```js
+-  pickHelper.pick(pickPosition, scene, camera, time);
++  pickHelper.pick(pickPosition, pickScene, camera, time);
+```
+
+이제 투명한 부분을 관통해 요소를 선택할 수 있습니다.
+
+{{{example url="../threejs-picking-gpu.html" }}}
+
+이 글이 피킹을 구현하는 데 도움이 되었으면 좋겠네요. 나중에 요소를 마우스로 조작하는 법에 대해서도 한 번 써보겠습니다.

+ 502 - 0
threejs/lessons/kr/threejs-post-processing-3dlut.md

@@ -0,0 +1,502 @@
+Title: 3DLUT로 후처리하기
+Description: Three.js에서 3DLUT로 후처리하는 법을 알아봅니다
+TOC: LUT 파일로 후처리 효과 적용하기
+
+이전 글에서는 [후처리(Post processing)](threejs-post-processing.html)에 관해 알아보았습니다. 보통 후처리는 LUT 또는 3DLUT라고 부르기도 합니다. LUT는 룩업 테이블(Look-Up Table, 순람표)의 줄임말이고, 3DLUT는 3차원 룩업 테이블의 줄임말입니다.
+
+3DLUT는 2D 이미지를 특정한 색상 정육면체를 매핑한다고 생각하면 쉽습니다. 먼저 원본 이미지의 색상을 정육면체의 인덱스 값과 매칭시킵니다. 원본 이미지의 픽셀 하나당 해당 픽셀 색상의 빨강(red), 초록(green), 파랑(blue) 값을 이용해 정육면체의 특정 지점을 가리키는(look-up) 3D 벡터 인덱스를 만드는 것이죠. 이 인덱스를 통해 3DLUT에서 뽑아낸 값을 새로운 색으로 사용하는 겁니다.
+
+자바스크립트의 경우 아래처럼 구현할 수 있습니다. RGB 각 색상값을 0부터 255의 정수로 표현한 3차원 256x256x256 배열로 룩업 테이블을 구현하고, 이 룩업 테이블에서 RGB 색상값을 이용해 새로운 색상값을 선택하는 거죠.
+
+```js
+const newColor = lut[origColor.red][origColor.green][origColor.blue]
+```
+
+물론 256x256x256 배열은 큰 배열입니다. [텍스처에 관한 글](threejs-textures.html)에서 배웠듯 텍스처는 크기에 상관 없이 0.0에서 1.0로 값을 지정합니다.
+
+8x8x8 정육면체를 예로 들어보죠.
+
+<div class="threejs_center"><img src="resources/images/3dlut-rgb.svg" class="noinvertdark" style="width: 500px"></div>
+
+먼저 0,0,0 부분을 검정색으로 채웁니다. 맞은편의 1,1,1 부분은 하얀색, 1,0,0 부분은 <span style="color:red;">빨강</span>, 0,1,0은 <span style="color:green;">초록</span>, 0,0,1은 <span style="color:blue;">파랑</span>으로 채웁니다.
+
+<div class="threejs_center"><img src="resources/images/3dlut-axis.svg" class="noinvertdark" style="width: 500px"></div>
+
+그리고 각 축을 따라 색을 채워넣습니다.
+
+<div class="threejs_center"><img src="resources/images/3dlut-edges.svg" class="noinvertdark" style="width: 500px"></div>
+
+빈 모서리를 2개 이상의 색상 채널을 사용하는 색으로 채웁니다(초록 + 빨강, 파랑 + 빨강 등).
+
+<div class="threejs_center"><img src="resources/images/3dlut-standard.svg" class="noinvertdark" style="width: 500px"></div>
+
+마지막으로 빈 공간을 채웁니다. 이 형태가 3DLUT 기본 구조입니다. 지금은 효과를 주기 전과 후의 차이가 없습니다. 색상값을 인덱스로 사용해 새로운 색상값을 선택하면, 정확히 같은 색상값이 나오기 때문이죠.
+
+<div class="threejs_center"><object type="image/svg+xml" data="resources/images/3dlut-standard-lookup.svg" class="noinvertdark" data-diagram="lookup" style="width: 600px"></object></div>
+
+이 정육면체를 호박색 쉐이드로 바꾸면 같은 인덱스를 참조하지만 전혀 다른 결과가 나옵니다.
+
+<div class="threejs_center"><object type="image/svg+xml" data="resources/images/3dlut-amber-lookup.svg" class="noinvertdark" data-diagram="lookup" style="width: 600px"></object></div>
+
+이 기법을 사용하면 룩업 테이블을 교체하는 것으로 많은 효과를 구현할 수 있습니다. 색상 계산 기반의 효과는 대부분 하나의 색상값만을 사용합니다. 색상, 대비, 채도, 컬러 캐스트(color cast), 틴트(tint), 밝기, 노출도, 레벨, 커브, 포스터화, 그림자, 강조, 등 거의 모든 효과를 색상값 계산을 기반으로 구현하죠. 또 이 모든 효과를 하나의 룩업 테이블로 합칠 수도 있습니다.
+
+룩업 테이블을 사용하려면 먼저 적용할 장면이 필요하니 간단한 장면을 하나 만들어보겠습니다. [glTF 불러오기](threejs-load-gltf.html)에서 배웠듯 glTF 파일을 불러와 사용하겠습니다. 예제에 사용할 모델은 [The Ice Wolves](https://sketchfab.com/sarath.irn.kat005)의 [작품](https://sketchfab.com/models/a1d315908e9f45e5a3bc618bdfd2e7ee)입니다.
+
+[배경과 하늘 상자](threejs-backgrounds.html)에서 배웠던 대로 배경도 추가하겠습니다.
+
+{{{example url="../threejs-postprocessing-3dlut-prep.html" }}}
+
+이제 장면을 구현했으니 3DLUT를 만들어야 합니다. 가장 간단한 3DLUT는 2x2x2 identity LUT로, 여기서 *identity(동일한)*은 아무런 변화도 없음을 의미합니다. 1을 곱하거나 아무것도 안 하는 경우와 같죠. LUT 안의 색상값을 사용한다고 해도 입력된 값과 정확히 같은 값을 반환할 테니까요.
+
+<div class="threejs_center"><img src="resources/images/3dlut-standard-2x2.svg" class="noinvertdark" style="width: 200px"></div>
+
+WebGL1은 3D 텍스쳐를 지원하지 않습니다. 따라서 3D 텍스처를 썰어 펼쳐 놓은 형태의 4x2짜리 2D 텍스처를 대신 사용하겠습니다.
+
+아래는 4x2 2D 텍스처로 identity LUT를 구현한 것입니다.
+
+```js
+const makeIdentityLutTexture = function() {
+  const identityLUT = new Uint8Array([
+      0,   0,   0, 255,  // black
+    255,   0,   0, 255,  // red
+      0,   0, 255, 255,  // blue
+    255,   0, 255, 255,  // magenta
+      0, 255,   0, 255,  // green
+    255, 255,   0, 255,  // yellow
+      0, 255, 255, 255,  // cyan
+    255, 255, 255, 255,  // white
+  ]);
+
+  return function(filter) {
+    const texture = new THREE.DataTexture(identityLUT, 4, 2, THREE.RGBAFormat);
+    texture.minFilter = filter;
+    texture.magFilter = filter;
+    texture.needsUpdate = true;
+    texture.flipY = false;
+    return texture;
+  };
+}();
+```
+
+필터가 들어간 것, 안 들어간 것 총 2개를 만들겠습니다.
+
+```js
+const lutTextures = [
+  { name: 'identity', size: 2, texture: makeIdentityLutTexture(THREE.LinearFilter) },
+  { name: 'identity not filtered', size: 2, texture: makeIdentityLutTexture(THREE.NearestFilter) },
+];
+```
+
+[후처리에 관한 글](threejs-post-processing.html)에서 작성했던 코드를 가져와 이 쉐이더들을 대신 쓰도록 합니다.
+
+```js
+const lutShader = {
+  uniforms: {
+    tDiffuse: { value: null },
+    lutMap:  { value: null },
+    lutMapSize: { value: 1, },
+  },
+  vertexShader: `
+    varying vec2 vUv;
+    void main() {
+      vUv = uv;
+      gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
+    }
+  `,
+  fragmentShader: `
+    #include <common>
+
+    #define FILTER_LUT true
+
+    uniform sampler2D tDiffuse;
+    uniform sampler2D lutMap;
+    uniform float lutMapSize;
+
+    varying vec2 vUv;
+
+    vec4 sampleAs3DTexture(sampler2D tex, vec3 texCoord, float size) {
+      float sliceSize = 1.0 / size;                  // space of 1 slice
+      float slicePixelSize = sliceSize / size;       // space of 1 pixel
+      float width = size - 1.0;
+      float sliceInnerSize = slicePixelSize * width; // space of size pixels
+      float zSlice0 = floor( texCoord.z * width);
+      float zSlice1 = min( zSlice0 + 1.0, width);
+      float xOffset = slicePixelSize * 0.5 + texCoord.x * sliceInnerSize;
+      float yRange = (texCoord.y * width + 0.5) / size;
+      float s0 = xOffset + (zSlice0 * sliceSize);
+
+      #ifdef FILTER_LUT
+
+        float s1 = xOffset + (zSlice1 * sliceSize);
+        vec4 slice0Color = texture2D(tex, vec2(s0, yRange));
+        vec4 slice1Color = texture2D(tex, vec2(s1, yRange));
+        float zOffset = mod(texCoord.z * width, 1.0);
+        return mix(slice0Color, slice1Color, zOffset);
+
+      #else
+
+        return texture2D(tex, vec2( s0, yRange));
+
+      #endif
+    }
+
+    void main() {
+      vec4 originalColor = texture2D(tDiffuse, vUv);
+      gl_FragColor = sampleAs3DTexture(lutMap, originalColor.xyz, lutMapSize);
+    }
+  `,
+};
+
+const lutNearestShader = {
+  uniforms: Object.assign( {}, lutShader.uniforms ),
+  vertexShader: lutShader.vertexShader,
+  fragmentShader: lutShader.fragmentShader.replace('#define FILTER_LUT', '//'),
+};
+```
+
+fragment 쉐이더의 다음 코드는
+
+```glsl
+#define FILTER_LUT true
+```
+
+주석 처리했던 두 번째 쉐이더를 생성하기 위한 것입니다.
+
+그리고 각 쉐이더로 `Pass`를 만듭니다.
+
+```js
+const effectLUT = new THREE.ShaderPass(lutShader);
+effectLUT.renderToScreen = true;
+const effectLUTNearest = new THREE.ShaderPass(lutNearestShader);
+effectLUTNearest.renderToScreen = true;
+```
+
+기존에 배경과 glTF를 별도 장면으로 분리했으므로 각 장면의 `RenderPass`를 따로 생성합니다.
+
+```js
+const renderModel = new THREE.RenderPass(scene, camera);
+renderModel.clear = false;  // 배경을 지우지 않도록 합니다
+const renderBG = new THREE.RenderPass(sceneBG, cameraBG);
+```
+
+다음으로 사용할 pass*를 `EffectComposer`에 추가합니다.
+
+※ 편의상 `Pass` 인스턴스를 pass로 번역합니다.
+
+```js
+const rtParameters = {
+  minFilter: THREE.LinearFilter,
+  magFilter: THREE.LinearFilter,
+  format: THREE.RGBFormat,
+};
+const composer = new THREE.EffectComposer(renderer, new THREE.WebGLRenderTarget(1, 1, rtParameters));
+
+composer.addPass(renderBG);
+composer.addPass(renderModel);
+composer.addPass(effectLUT);
+composer.addPass(effectLUTNearest);
+```
+
+GUI를 만들어 LUT를 바꿀 수 있도록 합니다.
+
+```js
+const lutNameIndexMap = {};
+lutTextures.forEach((info, ndx) => {
+  lutNameIndexMap[info.name] = ndx;
+});
+
+const lutSettings = {
+  lut: lutNameIndexMap.identity,
+};
+const gui = new GUI({ width: 300 });
+gui.add(lutSettings, 'lut', lutNameIndexMap);
+```
+
+마지막으로 필터링 여부에 따라 효과가 바뀌도록 설정합니다. LUT가 선택한 텍스처를 사용하도록 하고, `EffectComposer`로 렌더링 합니다.
+
+```js
+const lutInfo = lutTextures[lutSettings.lut];
+
+const effect = lutInfo.filter ? effectLUT : effectLUTNearest;
+effectLUT.enabled = lutInfo.filter;
+effectLUTNearest.enabled = !lutInfo.filter;
+
+const lutTexture = lutInfo.texture;
+effect.uniforms.lutMap.value = lutTexture;
+effect.uniforms.lutMapSize.value = lutInfo.size;
+
+composer.render(delta);
+```
+
+identity 3DLUT를 선택했을 때는 아무런 변화가 없습니다.
+
+{{{example url="../threejs-postprocessing-3dlut-identity.html" }}}
+
+하지만 필터가 identity not filtered LUT를 선택하면 재미있는 결과가 나옵니다.
+
+<div class="threejs_center"><img src="resources/images/unfiltered-3dlut.jpg" style="width: 500px"></div>
+
+왜 이런 결과가 나온 걸까요? 필터링을 사용할 경우(linear), GPU는 선형적으로 색상값을 채워넣습니다. 필터링을 사용하지 않을 경우(nearest), 알아서 색상값을 채워넣지 않기에 3DLUT에서(근처의) 색상값이 있는 곳을 찾아 사용하는 것이죠.
+
+어느정도 이해했다면 더 다양한 3DLUT를 만들어봅시다.
+
+먼저 룩업 테이블의 해상도를 정하고 간단한 코드를 만들어 룩업 테이블 정육면체의 각 면을 만들겠습니다.
+
+```js
+const ctx = document.querySelector('canvas').getContext('2d');
+
+function drawColorCubeImage(ctx, size) {
+  const canvas = ctx.canvas;
+  canvas.width = size * size;
+  canvas.height = size;
+
+  for (let zz = 0; zz < size; ++zz) {
+    for (let yy = 0; yy < size; ++yy) {
+      for (let xx = 0; xx < size; ++xx) {
+        const r = Math.floor(xx / (size - 1) * 255);
+        const g = Math.floor(yy / (size - 1) * 255);
+        const b = Math.floor(zz / (size - 1) * 255);
+        ctx.fillStyle = `rgb(${ r },${ g },${ b })`;
+        ctx.fillRect(zz * size + xx, yy, 1, 1);
+      }
+    }
+  }
+  document.querySelector('#width').textContent = canvas.width;
+  document.querySelector('#height').textContent = canvas.height;
+}
+
+drawColorCubeImage(ctx, 8);
+```
+
+캔버스 요소도 만듭니다.
+
+```html
+<canvas></canvas>
+```
+
+이제 어떤 identity 3D 룩업 테이블이든 만들 수 있습니다.
+
+{{{example url="../3dlut-base-cube-maker.html" }}}
+
+해상도가 높을수록 더 세밀한 효과를 줄 수 있지만 정육면체형 데이터의 크기는 기하급수적으로 늘어납니다. 크기 8x8 정육면체는 2kb 정도지만 64x64 정육면체는 약 1mb나 되죠. 그러니 충분히 효과를 구현할 수 있는 만큼만 사용하는 게 좋습니다.
+
+사이즈를 16으로 설정하고 `Save...` 버튼을 클릭하면 아래와 같은 파일이 나옵니다.
+
+<div class="threejs_center"><img src="resources/images/identity-lut-s16.png"></div>
+
+그리고 LUT를 적용할 화면을 캡쳐해야 합니다. 이 경우에는 이전에 만든 장면에 아무런 효과를 주지 않은 화면이겠죠. 대게 위 예제를 오른쪽 클릭해 "다른 이름으로 저장..."을 클릭하면 되지만, OS에 따라 마우스 우클릭이 동작하지 않을 수 있습니다. 제 경우 OS에 내장된 스크린샷 기능을 이용해 화면을 캡쳐했습니다*.
+
+※ Windows 10 RS5(레드스톤 5) 이상이라면 `Windows + Shift + S`를 눌러 화면을 캡쳐할 수 있습니다. 역주.
+
+<div class="threejs_center"><img src="resources/images/3dlut-screen-capture.jpg" style="width: 600px"></div>
+
+캡쳐본을 이미지 에디터에서 불러옵니다. 저는 포토샵을 사용해 샘플 이미지를 불러오고, 한쪽 귀퉁이에 3DLUT를 붙여 넣었습니다.
+
+> 참고: 제 경우 포토샵에서 캡쳐본 위에 lut 파일을 불러오려고 했을 때 이미지가 두 배 더 커졌습니다. 아마 DPI를 맞추거나 하는 이유 때문에 그런 거겠죠. lut 파일을 별도 탭에 불러와 캡쳐본 위에 복사 붙여 넣기 하니 정상적으로 불러와지더군요.
+
+<div class="threejs_center"><img src="resources/images/3dlut-photoshop-before.jpg" style="width: 600px"></div>
+
+그리고 이미지에 부여하고 싶은 색상 효과를 부여합니다. 포토샵의 경우 대부분의 효과는 이미지(Image)->조정(Adjustments) 메뉴에 있습니다.
+
+<div class="threejs_center"><img src="resources/images/3dlut-photoshop-after.jpg" style="width: 600px"></div>
+
+색상을 조정하면 3DLUT 이미지에도 같은 효과가 적용될 겁니다.
+
+자 그럼 이제 이걸 어떻게 쓸 수 있을까요?
+
+먼저 저는 3DLUT 이미지를 `3dlut-red-only-s16.png`라는 이름으로 저장했습니다. 메모리를 아끼려면 이미지를 LUT 부분만 잘라 16x256로 맞추는 것이 좋지만, 그냥 재미삼아 이미지를 불러온 이후 자르겠습니다*. 이 방법의 장점은 귀찮게 이미지를 자르는 과정 없이 효과를 적용해보고 싶은 대로 바로바로 적용할 수 있다는 것이죠. 물론 대역폭을 낭비한다는 게 단점입니다.
+
+※ 포토샵 CC 이후 버젼을 사용한다면 레이어를 오른쪽 클릭해 `PNG로 빠르게 내보내기` 메뉴로 해당 그룹 또는 레이어만 .png 파일로 내보낼 수 있습니다. 이미지를 귀찮게 자르는 과정 없이 .png 파일을 바로 생성할 수 있죠. 역주.
+
+아래는 이미지를 불러오는 코드입니다. 실제 코드에서는 텍스처를 불러왔을 때 바로 사용할 수 있도록 identity lut를 먼저 만들었습니다. 그 다음 이미지를 불러와 3DLUT 부분만 캔버스에 복사하고, 캔버스에서 가져온 데이터를 텍스처에 지정합니다. 또한 텍스처가 바뀌었을 때 바로 적용하도록 `needsUpdate` 속성도 true로 설정합니다.
+
+```js
+const makeLUTTexture = function() {
+  const imgLoader = new THREE.ImageLoader();
+  const ctx = document.createElement('canvas').getContext('2d');
+
+  return function(info) {
+    const texture = makeIdentityLutTexture(
+        info.filter ? THREE.LinearFilter : THREE.NearestFilter);
+
+    if (info.url) {
+      const lutSize = info.size;
+
+      /**
+       * 크기를 2(identity LUT의 크기)로 설정합니다. 이 크기는 나중에 이미지를
+       * 불러온 뒤 복원합니다. 이러면 lut를 사용하는 코드는 이미지의 적용 여부를
+       * 신경쓰지 않아도 됩니다.
+       **/
+      info.size = 2;
+
+      imgLoader.load(info.url, function(image) {
+        const width = lutSize * lutSize;
+        const height = lutSize;
+        info.size = lutSize;
+        ctx.canvas.width = width;
+        ctx.canvas.height = height;
+        ctx.drawImage(image, 0, 0);
+        const imageData = ctx.getImageData(0, 0, width, height);
+
+        texture.image.data = new Uint8Array(imageData.data.buffer);
+        texture.image.width = width;
+        texture.image.height = height;
+        texture.needsUpdate = true;
+      });
+    }
+
+    return texture;
+  };
+}();
+```
+
+기존 코드가 LUT png 파일을 사용하도록 수정합니다.
+
+```js
+const lutTextures = [
+  { name: 'identity',           size: 2, filter: true , },
+  { name: 'identity no filter', size: 2, filter: false, },
++  { name: 'custom',          url: 'resources/images/lut/3dlut-red-only-s16.png' },
+];
+
++lutTextures.forEach((info) => {
++  // 사이즈값이 없다면 사이즈 정보를 파일 이름에서 가져옵니다.
++  if (!info.size) {
++    /**    
++     * 파일 이름이 '-s<숫자>[n]' 이렇게 끝난다고 가정합니다.
++     * <숫자>는 3DLUT 정육면체의 크기입니다.
++     * [n]은 '필터링 없음' 또는 'nearest'를 의미합니다.
++     *
++     * 예시:
++     *    'foo-s16.png' = 크기:16, 필터: true
++     *    'bar-s8n.png' = 크기:8, 필터: false
++     **/
++    const m = /-s(\d+)(n*)\.[^.]+$/.exec(info.url);
++    if (m) {
++      info.size = parseInt(m[1]);
++      info.filter = info.filter === undefined ? m[2] !== 'n' : info.filter;
++    }
++  }
++
++  info.texture = makeLUTTexture(info);
++});
+```
+
+위 코드가 LUT의 사이즈를 파일 이름에 인코딩한 예입니다. 이러면 png로 LUT를 바꾸기가 훨씬 쉽죠.
+
+그냥은 좀 심심하니 lut png 파일을 더 많이 만들어봅시다.
+
+```js
+const lutTextures = [
+  { name: 'identity',           size: 2, filter: true , },
+  { name: 'identity no filter', size: 2, filter: false, },
+  { name: 'custom',          url: 'resources/images/lut/3dlut-red-only-s16.png' },
++  { name: 'monochrome',      url: 'resources/images/lut/monochrome-s8.png' },
++  { name: 'sepia',           url: 'resources/images/lut/sepia-s8.png' },
++  { name: 'saturated',       url: 'resources/images/lut/saturated-s8.png', },
++  { name: 'posterize',       url: 'resources/images/lut/posterize-s8n.png', },
++  { name: 'posterize-3-rgb', url: 'resources/images/lut/posterize-3-rgb-s8n.png', },
++  { name: 'posterize-3-lab', url: 'resources/images/lut/posterize-3-lab-s8n.png', },
++  { name: 'posterize-4-lab', url: 'resources/images/lut/posterize-4-lab-s8n.png', },
++  { name: 'posterize-more',  url: 'resources/images/lut/posterize-more-s8n.png', },
++  { name: 'inverse',         url: 'resources/images/lut/inverse-s8.png', },
++  { name: 'color negative',  url: 'resources/images/lut/color-negative-s8.png', },
++  { name: 'high contrast',   url: 'resources/images/lut/high-contrast-bw-s8.png', },
++  { name: 'funky contrast',  url: 'resources/images/lut/funky-contrast-s8.png', },
++  { name: 'nightvision',     url: 'resources/images/lut/nightvision-s8.png', },
++  { name: 'thermal',         url: 'resources/images/lut/thermal-s8.png', },
++  { name: 'b/w',             url: 'resources/images/lut/black-white-s8n.png', },
++  { name: 'hue +60',         url: 'resources/images/lut/hue-plus-60-s8.png', },
++  { name: 'hue +180',        url: 'resources/images/lut/hue-plus-180-s8.png', },
++  { name: 'hue -60',         url: 'resources/images/lut/hue-minus-60-s8.png', },
++  { name: 'red to cyan',     url: 'resources/images/lut/red-to-cyan-s8.png' },
++  { name: 'blues',           url: 'resources/images/lut/blues-s8.png' },
++  { name: 'infrared',        url: 'resources/images/lut/infrared-s8.png' },
++  { name: 'radioactive',     url: 'resources/images/lut/radioactive-s8.png' },
++  { name: 'goolgey',         url: 'resources/images/lut/googley-s8.png' },
++  { name: 'bgy',             url: 'resources/images/lut/bgy-s8.png' },
+];
+```
+
+아래 예제에서 여러 lut를 시험해볼 수 있습니다.
+
+{{{example url="../threejs-postprocessing-3dlut.html" }}}
+
+추가로 한 가지 덧붙이겠습니다. 인터넷을 뒤져보니 Adobe에서 만든 표준 LUT 형식이 있더군요. [인터넷에서 검색](https://www.google.com/search?q=lut+files)해보면 이런 LUT 형식의 파일을 쉽게 찾을 수 있을 겁니다.
+
+이를 기반으로 간단하게 로더를 작성했습니다. 총 4가지 형식이 있다고는 하나, 제가 찾은 형식은 하나뿐이라 모든 형식에서 테스트하진 못했습니다.
+
+여기에 간단한 드래그-앤-드롭 라이브러리도 만들었습니다. 이 두 라이브러리를 이용해 여러분이 직접 LUT 파일을 적용할 수 있도록 말이죠.
+
+먼저 앞서 만든 두 라이브러리를 불러온 뒤
+
+```js
+import * as lutParser from './resources/lut-reader.js';
+import * as dragAndDrop from './resources/drag-and-drop.js';
+```
+
+아래처럼 사용합니다.
+
+```js
+dragAndDrop.setup({ msg: 'Drop LUT File here' });
+dragAndDrop.onDropFile(readLUTFile);
+
+function readLUTFile(file) {
+  const reader = new FileReader();
+  reader.onload = (e) => {
+    const lut = lutParser.lutTo2D3Drgb8(lutParser.parse(e.target.result));
+    const {size, data, name} = lut;
+    const texture = new THREE.DataTexture(data, size * size, size, THREE.RGBFormat);
+    texture.minFilter = THREE.LinearFilter;
+    texture.needsUpdate = true;
+    texture.flipY = false;
+    const lutTexture = {
+      name: (name && name.toLowerCase().trim() !== 'untitled')
+          ? name
+          : file.name,
+      size: size,
+      filter: true,
+      texture,
+    };
+    lutTextures.push(lutTexture);
+    lutSettings.lut = lutTextures.length - 1;
+    updateGUI();
+  };
+
+  reader.readAsText(file);
+}
+```
+
+GUI가 새로 불러온 파일을 반영하도록 코드를 추가합니다.
+
+```js
+const lutSettings = {
+  lut: lutNameIndexMap.thermal,
+};
+const gui = new GUI({ width: 300 });
+gui.addFolder('Choose LUT or Drag&Drop LUT File(s)');
+
+let lutGUI;
+function updateGUI() {
+  makeLutNameIndexMap();
+  if (lutGUI) {
+    gui.remove(lutGUI);
+  }
+  lutGUI = gui.add(lutSettings, 'lut', lutNameIndexMap);
+}
+updateGUI();
+```
+
+이제 [Adobe LUT 파일](https://www.google.com/search?q=lut+files)을 다운해 아래 예제에 드래그-앤-드롭으로 불러올 수 있을 겁니다.
+
+{{{example url="../threejs-postprocessing-3dlut-w-loader.html" }}}
+
+다만 Adobe LUT는 온라인 환경에 최적화되지 않았습니다. 파일 용량이 꽤 큰 편이죠. 아래 예제를 사용하면 용량을 좀 더 줄일 수 있습니다. 드래그-앤-드롭으로 파일을 불러오고 크기를 선택한 뒤 "Save..." 버튼을 클릭하면 되죠.
+
+아래 예제는 단순히 위에서 썼던 예제를 조금 수정한 것입니다. glFT 파일 없이 배경만 렌더링한 것이죠. 배경 이미지는 아까 본 스크립트로 만든 identity lut 이미지입니다. 여기에 LUT 파일을 불러와 해당 LUT 파일을 PNG로 만드는 데 사용하는 것이죠.
+
+{{{example url="../threejs-postprocessing-adobe-lut-to-png-converter.html" }}}
+
+이 글에서는 쉐이더가 어떻게 작동하는지에 대해서는 아예 설명하지 않았습니다. 나중에 GLSL에 대해 더 다룰 기회가 있었으면 좋겠네요. 쉐이더의 작동 방식을 알고 싶다면 [후처리에 관한 글](threejs-post-processing.html)에 있는 링크 또는 [이 유튜브 영상](https://www.youtube.com/watch?v=rfQ8rKGTVlg#t=24m30s)을 참고하기 바랍니다.
+
+<script type="module" src="../resources/threejs-post-processing-3dlut.js"></script>

+ 254 - 0
threejs/lessons/kr/threejs-post-processing.md

@@ -0,0 +1,254 @@
+Title: Three.js 후처리
+Description: Three.js로 후처리하는 법을 알아봅니다
+TOC: 후처리
+
+*후처리(post processing)*란 보통 2D 이미지에 어떤 효과나 필터를 넣는 것을 의미합니다. Three.js는 다양한 mesh로 이루어진 장면을 2D 이미지로 렌더링하죠. 일반적으로 이 이미지는 바로 캔버스를 통해 브라우저 화면에 렌더링됩니다. 하지만 대신 이 이미지를 [렌더 타겟에 렌더링하고](threejs-rendertargets.html) 캔버스에 보내기 전 임의의 *후처리* 효과를 줄 수 있습니다.
+
+인스타그램 필터, 포토샵 필터 등이 후처리의 좋은 예이죠.
+
+Three.js에는 후처리를 순차적으로 처리해주는 모범 클래스가 있습니다. 일단 `EffectComposer`의 인스턴스를 만들고 여기에 `Pass` 객체(효과, 필터)들을 추가합니다. 그리고 `EffectComposer.render` 메서드를 호출하면 현재 장면을 [렌더 타겟](threejs-rendertargets.html)에 렌더링한 뒤 각 pass*를 순서대로 적용합니다.
+
+※ 편의상 `Pass` 인스턴스를 pass로 번역합니다.
+
+이 pass는 비넷(vignette), 흐림(blur), 블룸(bloom), 필름 그레인(film grain) 효과 또는 hue, 채도(saturation), 대비(contrast) 조정 등의 후처리 효과로, 이 효과를 모두 적용한 결과물을 최종적으로 캔버스에 렌더링합니다.
+
+여기서 어느 정도 `EffectComposer`의 원리를 이해할 필요가 있습니다. `EffectComposer`는 두 개의 [렌더 타겟](threejs-rendertargets.html)을 사용합니다. 편의상 이 둘을 **rtA**, **rtB**라고 부르도록 하죠.
+
+`EffectComposer.addPass`를 각 pass를 적용할 순서대로 호출하고 `EffectComposer.render`를 호출하면 pass*는 아래 그림과 같은 순서로 적용됩니다.
+
+<div class="threejs_center"><img src="resources/images/threejs-postprocessing.svg" style="width: 600px"></div>
+
+먼저 `RenderPass`에 넘긴 장면을 **rtA**에 렌더링합니다. 그리고 **rtA**를 다음 pass에 넘겨주면 해당 pass는 **rtA**에 pass를 적용한 결과를 **rtB**에 렌더링합니다. 그런 다음 **rtB**를 다음 pass로 넘겨 적용한 결과를 **rtA**에, **rtA**에 pass를 적용한 결과를 다시 **rtB**에, 이런 식으로 모든 pass가 끝날 때까지 계속 반복합니다.
+
+`Pass`에는 공통적으로 4가지 옵션이 있습니다.
+
+## `enabled`
+
+이 pass를 사용할지의 여부입니다.
+
+## `needsSwap`
+
+이 pass를 적용한 후 `rtA`와 `rtB`를 바꿀지의 여부입니다.
+
+## `clear`
+
+이 pass를 적용하기 전에 화면을 초기화할지의 여부입니다.
+
+## `renderToScreen`
+
+지정한 렌더 타겟이 아닌 캔버스에 렌더링할지의 여부입니다. 보통 `EffectComposer`에 추가하는 마지막 pass에 이 옵션을 true로 설정합니다.
+
+간단한 예제를 만들어봅시다. [반응형 디자인에 관한 글](threejs-responsive.html)에서 썼던 예제를 가져오겠습니다.
+
+추가로 먼저 `EffectComposer` 인스턴스를 생성합니다.
+
+```js
+const composer = new EffectComposer(renderer);
+```
+
+다음으로 `RenderPass`를 첫 pass로 추가합니다. 이 pass는 넘겨 받은 장면을 첫 렌더 타겟에 렌더링할 겁니다.
+
+```js
+composer.addPass(new RenderPass(scene, camera));
+```
+
+다음으로 `BloomPass`를 추가합니다. `BloomPass`는 장면을 원래의 장면보다 작게 렌더링해 흐림(blur) 효과를 줍니다. 그리고 효과가 적용된 장면을 원래 장면에 덮어 씌우는 식으로 *블룸* 효과를 구현합니다.
+
+```js
+const bloomPass = new BloomPass(
+    1,    // 강도
+    25,   // 커널(kernel) 크기
+    4,    // 시그마 ?
+    256,  // 렌더 타겟의 해상도를 낮춤
+);
+composer.addPass(bloomPass);
+```
+
+마지막으로 원본 장면에 노이즈와 스캔라인(scanline)을 추가하는 `FilmPass`를 추가합니다.
+
+```js
+const filmPass = new FilmPass(
+    0.35,   // 노이즈 강도
+    0.025,  // 스캔라인 강도
+    648,    // 스캔라인 개수
+    false,  // 흑백
+);
+filmPass.renderToScreen = true;
+composer.addPass(filmPass);
+```
+
+`filmPass`가 마지막 pass이기에 캔버스에 결과를 바로 렌더링하도록 `renderToScreen` 옵션을 true로 설정했습니다. 이 옵션을 설정하지 않으면 캔버스가 아닌 다음 렌더 타겟에 장면을 렌더링할 거예요.
+
+또 이 클래스들을 사용하기 위해 여러 스크립트를 불러와야 합니다.
+
+```js
+import { EffectComposer } from './resources/threejs/r115/examples/jsm/postprocessing/EffectComposer.js';
+import { RenderPass } from './resources/threejs/r115/examples/jsm/postprocessing/RenderPass.js';
+import { BloomPass } from './resources/threejs/r115/examples/jsm/postprocessing/BloomPass.js';
+import { FilmPass } from './resources/threejs/r115/examples/jsm/postprocessing/FilmPass.js';
+```
+
+대부분의 후처리에는 `EffectComposer.js`와 `RenderPass.js`가 필수입니다.
+
+이제 `WebGLRenderer.render` 대신 `EffectComposer.render`를 사용*하고* `EffectComposer`가 결과물을 캔버스의 크기에 맞추도록 해야 합니다.
+
+```js
+-function render(now) {
+-  time *= 0.001;
++let then = 0;
++function render(now) {
++  now *= 0.001;  // 초 단위로 변환
++  const deltaTime = now - then;
++  then = now;
+
+  if (resizeRendererToDisplaySize(renderer)) {
+    const canvas = renderer.domElement;
+    camera.aspect = canvas.clientWidth / canvas.clientHeight;
+    camera.updateProjectionMatrix();
++    composer.setSize(canvas.width, canvas.height);
+  }
+
+  cubes.forEach((cube, ndx) => {
+    const speed = 1 + ndx * .1;
+-    const rot = time * speed;
++    const rot = now * speed;
+    cube.rotation.x = rot;
+    cube.rotation.y = rot;
+  });
+
+-  renderer.render(scene, camera);
++  composer.render(deltaTime);
+
+  requestAnimationFrame(render);
+}
+```
+
+`EffectComposer.render` 메서드는 인자로 마지막 프레임을 렌더링한 이후의 시간값인 `deltaTime`을 인자로 받습니다. pass에 애니메이션이 필요할 경우를 대비해 이 값을 넘겨주기 위해서이죠. 예제의 경우에는 `FilmPass`에 애니메이션이 있습니다.
+
+{{{example url="../threejs-postprocessing.html" }}}
+
+런타임에 효과의 속성을 변경할 때는 보통 uniform의 value 값을 바꿉니다. GUI를 추가해 이 속성을 조정할 수 있게 만들어보죠. 어떤 속성을 어떻게 조작할 수 있는지는 해당 효과의 소스 코드를 열어봐야 알 수 있습니다.
+
+[`BloomPass.js`](https://github.com/mrdoob/three.js/blob/master/examples/js/postprocessing/BloomPass.js)에서
+아래 코드를 찾았습니다.
+
+```js
+this.copyUniforms[ "opacity" ].value = strength;
+```
+
+아래처럼 하면 강도를 런타임에 바꿀 수 있겠네요.
+
+```js
+bloomPass.copyUniforms.opacity.value = someValue;
+```
+
+마찬가지로 [`FilmPass.js`](https://github.com/mrdoob/three.js/blob/master/examples/js/postprocessing/FilmPass.js)에서
+아래 코드를 찾았습니다.
+
+```js
+if ( grayscale !== undefined )	this.uniforms.grayscale.value = grayscale;
+if ( noiseIntensity !== undefined ) this.uniforms.nIntensity.value = noiseIntensity;
+if ( scanlinesIntensity !== undefined ) this.uniforms.sIntensity.value = scanlinesIntensity;
+if ( scanlinesCount !== undefined ) this.uniforms.sCount.value = scanlinesCount;
+```
+
+이제 어떻게 값을 지정해야 하는지 알았으니 이 값을 조작하는 GUI를 만들어봅시다.
+
+```js
+import { GUI } from '../3rdparty/dat.gui.module.js';
+```
+
+일단 모듈을 로드합니다.
+
+```js
+const gui = new GUI();
+{
+  const folder = gui.addFolder('BloomPass');
+  folder.add(bloomPass.copyUniforms.opacity, 'value', 0, 2).name('strength');
+  folder.open();
+}
+{
+  const folder = gui.addFolder('FilmPass');
+  folder.add(filmPass.uniforms.grayscale, 'value').name('grayscale');
+  folder.add(filmPass.uniforms.nIntensity, 'value', 0, 1).name('noise intensity');
+  folder.add(filmPass.uniforms.sIntensity, 'value', 0, 1).name('scanline intensity');
+  folder.add(filmPass.uniforms.sCount, 'value', 0, 1000).name('scanline count');
+  folder.open();
+}
+```
+
+이제 각 설정을 조작할 수 있습니다.
+
+{{{example url="../threejs-postprocessing-gui.html" }}}
+
+여기까지 잘 따라왔다면 이제 효과를 직접 만들어볼 수 있습니다.
+
+후처리 효과는 쉐이더를 사용합니다. 쉐이더는 [GLSL (Graphics Library Shading Language)](https://www.khronos.org/files/opengles_shading_language.pdf)이라는 언어를 사용하죠. 언어가 방대해 이 글에서 전부 다루기는 어렵습니다. 기초부터 알아보고 싶다면 [이 글](https://webglfundamentals.org/webgl/lessons/ko/webgl-shaders-and-glsl.html)과 [쉐이더란 무엇인가(The Book of Shaders)](https://thebookofshaders.com/)를 읽어보기 바랍니다.
+
+직접 예제를 만들어보는 게 도움이 될 테니 간단한 GLSL 후처리 쉐이더를 만들어봅시다. 이미지에 특정 색을 혼합하는 쉐이더를 만들 겁니다.
+
+Three.js에는 후처리를 도와주는 `ShaderPass` 헬퍼 클래스가 있습니다. 인자로 vertex 쉐이더, fragment 쉐이더, 기본값으로 이루어진 객체를 받죠. 이 클래스는 이전 pass의 결과물에서 어떤 텍스처를 읽을지, 그리고 `EffectComposer`의 렌더 타겟과 캔버스 중 어디에 렌더링할지를 결정할 겁니다.
+
+아래는 이전 pass의 결과물에 특정 색을 혼합하는 간단한 후처리 쉐이더입니다.
+
+```js
+const colorShader = {
+  uniforms: {
+    tDiffuse: { value: null },
+    color:    { value: new THREE.Color(0x88CCFF) },
+  },
+  vertexShader: `
+    varying vec2 vUv;
+    void main() {
+      vUv = uv;
+      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1);
+    }
+  `,
+  fragmentShader: `
+    varying vec2 vUv;
+    uniform sampler2D tDiffuse;
+    uniform vec3 color;
+    void main() {
+      vec4 previousPassColor = texture2D(tDiffuse, vUv);
+      gl_FragColor = vec4(
+          previousPassColor.rgb * color,
+          previousPassColor.a);
+    }
+  `,
+};
+```
+
+위 코드에서 `tDiffuse`는 이전 pass의 결과물을 받아오기 위한 것으로 거의 모든 경우에 필수입니다. 그리고 그 바로 밑에 `color` 속성을 Three.js의 `Color`로 선언했습니다.
+
+다음으로 vertex 쉐이더를 작성해야 합니다. 위 코드에서 작성한 vertex 쉐이더는 후처리에서 거의 표준처럼 사용하는 코드로, 대부분의 경우 바꿀 필요가 없습니다. 뭔가 많이 설정한 경우(아까 언급한 링크 참조)가 아니라면 `uv`, `projectionMatrix`, `modelViewMatrix`, `position` 변수는 Three.js가 알아서 넣어줍니다.
+
+마지막으로 fragment 쉐이더를 생성합니다. 아래 코드로 이전 pass에서 넘겨준 결과물의 픽셀 색상값을 가져올 수 있습니다.
+
+```glsl
+vec4 previousPassColor = texture2D(tDiffuse, vUv);
+```
+
+여기에 지정한 색상을 곱해 `gl_FragColor`에 결과를 저장합니다.
+
+```glsl
+gl_FragColor = vec4(
+    previousPassColor.rgb * color,
+    previousPassColor.a);
+```
+
+추가로 간단한 GUI를 만들어 rgb의 각 색상값을 조정할 수 있도록 합니다.
+
+```js
+const gui = new GUI();
+gui.add(colorPass.uniforms.color.value, 'r', 0, 4).name('red');
+gui.add(colorPass.uniforms.color.value, 'g', 0, 4).name('green');
+gui.add(colorPass.uniforms.color.value, 'b', 0, 4).name('blue');
+```
+
+색을 혼합하는 간단한 후처리 쉐이더를 완성했습니다.
+
+{{{example url="../threejs-postprocessing-custom.html" }}}
+
+언급했듯 이 글에서 GLSL의 작성법과 사용자 지정 쉐이더를 만드는 법을 모두 다루기는 무리입니다. WebGL이 어떻게 동작하는지 알고 싶다면 [이 시리즈](https://webglfundamentals.org)를 참고하세요. [Three.js의 후처리 쉐이더 소스 코드](https://github.com/mrdoob/three.js/tree/master/examples/js/shaders)를 분석하는 것도 좋은 방법입니다. 상대적으로 복잡한 쉐이더도 있지만 작은 것부터 차근차근 살펴본다면 언젠가 전체를 이해할 수 있을 거예요.
+
+아쉽게도 Three.js의 후처리 효과 대부분은 공식 문서가 없어 [예제를 참고하거나](https://github.com/mrdoob/three.js/tree/master/examples) [후처리 효과의 소스 코드](https://github.com/mrdoob/three.js/tree/master/examples/js/postprocessing)를 직접 분석해야 합니다. 부디 이 글과 이 시리즈의 [렌더 타겟에 관한 글](threejs-rendertargets.html)이 좋은 출발점을 마련해주었으면 좋겠네요.

+ 58 - 4
threejs/lessons/kr/threejs-prerequisites.md

@@ -148,12 +148,50 @@ ES5에서 배열에 [`forEach`](https://developer.mozilla.org/ko/docs/Web/JavaSc
 
 기존 방법
 
-     const width = dims.width;
-     const height = dims.height;
+```js
+const width = dims.width;
+const height = dims.height;
+```
+
+새로운 방법
+
+```js
+const { width, height } = dims;
+```
+
+구조분해할당은 배열에도 적용할 수 있습니다. `const position = [1, 2, 3, 4]`.
+이런 배열이 있다고 해보죠.
+
+기존 방법
+
+```js
+const x = position[2];
+const y = position[1];
+```
 
 새로운 방법
 
-     const { width, height } = dims;
+```js
+const [ , y, x ] = position;
+```
+
+또한 매개변수에도 구조분해할당을 적용할 수 있습니다.
+
+```js
+const dims = { width: 300, height: 150 };
+const position = [1, 2, 3, 4];
+
+function distFromOrig([x, y]) {
+  return Math.sqrt(x * x + y * y);
+}
+
+const dist = distFromOrig(position);  // dist = 2.236...
+
+function area({ width, height }) {
+  return width * height;
+}
+const a = area(dims);  // a = 45000
+```
 
 ### 객체 선언 시 축약 문법 사용
 
@@ -193,7 +231,7 @@ ES5에서 배열에 [`forEach`](https://developer.mozilla.org/ko/docs/Web/JavaSc
  function log(className, ...args) {
    const elem = document.createElement('div');
    elem.className = className;
-   elem.textContent = [...args].join(' ');
+   elem.textContent = args.join(' ');
    document.body.appendChild(elem);
  }
 ```
@@ -205,6 +243,22 @@ const position = [1, 2, 3];
 somemesh.position.set(...position);
 ```
 
+배열을 얕은 복사할 때 사용할 수도 있고
+
+```js
+const copiedPositionArray = [...position];
+copiedPositionArray.push(4); // [1,2,3,4] 
+console.log(position); // [1,2,3] 기존 배열은 영향을 받지 않음
+```
+
+객체를 합칠 때도 사용할 수 있죠.
+
+```
+const a = { abc: 123 };
+const b = { def: 456 };
+const c = { ...a, ...b };  // c = { abc: 123, def: 456 }
+```
+
 ### `class` 사용하기
 
 ES5 이하의 문법으로 클래스 스타일의 객체를 만드는 방법은 다른 개발자들에게 낯선 요소

+ 227 - 0
threejs/lessons/kr/threejs-rendering-on-demand.md

@@ -0,0 +1,227 @@
+Title: 불필요한 렌더링 없애기
+Description: 불필요한 렌더링을 제거합니다
+TOC: 불필요한 렌더링 없애기
+
+대부분의 개발자에게 이 주제는 너무 뻔할 수 있지만, 필요한 누군가를 위해
+글을 써보려 합니다. 대부분의 Three.js 예제는 렌더링 과정을 계속 반복합니다.
+그러니까 아래와 같이 재귀적으로 `requestAnimationFrame` 함수를 사용한다는
+거죠.
+
+```js
+function render() {
+  ...
+  requestAnimationFrame(render);
+}
+requestAnimationFrame(render);
+```
+
+계속 애니메이션이 있는 경우에야 별 상관이 없지만, 애니메이션이 없는 경우라면
+어떨까요? 이 경우 불필요한 렌더링을 반복하는 것은 연산 낭비일 뿐더러 사용
+환경이 모바일이라면 사용자의 배터리까지 낭비하는 셈입니다.
+
+처음 한 번만 렌더링하고, 그 후에 변화가 있을 때만 렌더링하는 것이 가장 정확한
+해결책일 겁니다. 여기서 변화란 텍스처나 모델의 로딩이 끝났을 때, 외부에서
+데이터를 받았을 때, 사용자가 카메라를 조정하거나, 설정을 바꾸거나, 인풋 값이
+변경된 경우 등 다양하겠죠.
+
+[반응형 디자인에 관한 글](threejs-responsive.html)에서 썼던 예제를 수정해
+필요에 따른 렌더링을 구현해봅시다.
+
+먼저 뭔가 변화를 일으킬 수 있는 요소가 필요하니 `OrbitControls`를 추가합니다.
+
+```js
+import * as THREE from './resources/three/r115/build/three.module.js';
++import { OrbitControls } from './resources/threejs/r115/examples/jsm/controls/OrbitControls.js';
+
+...
+
+const fov = 75;
+const aspect = 2;  // canvas 기본값
+const near = 0.1;
+const far = 5;
+const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
+camera.position.z = 2;
+
++const controls = new OrbitControls(camera, canvas);
++controls.target.set(0, 0, 0);
++controls.update();
+```
+
+정육면체에 애니메이션을 넣지 않을 것이니 이들을 참조할 필요가 없습니다.
+
+```js
+-const cubes = [
+-  makeInstance(geometry, 0x44aa88,  0),
+-  makeInstance(geometry, 0x8844aa, -2),
+-  makeInstance(geometry, 0xaa8844,  2),
+-];
++makeInstance(geometry, 0x44aa88,  0);
++makeInstance(geometry, 0x8844aa, -2);
++makeInstance(geometry, 0xaa8844,  2);
+```
+
+애니메이션과 `requestAnimationFrame` 관련 코드도 제거합니다.
+
+```js
+-function render(time) {
+-  time *= 0.001;
++function render() {
+
+  if (resizeRendererToDisplaySize(renderer)) {
+    const canvas = renderer.domElement;
+    camera.aspect = canvas.clientWidth / canvas.clientHeight;
+    camera.updateProjectionMatrix();
+  }
+
+-  cubes.forEach((cube, ndx) => {
+-    const speed = 1 + ndx * .1;
+-    const rot = time * speed;
+-    cube.rotation.x = rot;
+-    cube.rotation.y = rot;
+-  });
+
+  renderer.render(scene, camera);
+
+-  requestAnimationFrame(render);
+}
+
+-requestAnimationFrame(render);
+```
+
+그리고 `render` 함수를 직접 호출합니다.
+
+```js
+render();
+```
+
+이제 `OrbitControls`가 카메라 설정을 바꿀 때마다 직접 `render` 함수를 호출해야
+합니다. 뭔가 복잡할 것 같지만 다행히 `OrbitControls`에는 `change` 이벤트가 있습니다.
+
+```js
+controls.addEventListener('change', render);
+```
+
+또한 창 크기가 바뀔 때의 동작도 직접 처리해야 합니다. `render` 함수를 계속 호출할
+때는 해당 동작을 자동으로 처리했지만, 지금은 `render` 함수를 수동으로 호출하므로
+창의 크기가 바뀔 때 `render` 함수를 호출하도록 하겠습니다.
+
+```js
+window.addEventListener('resize', render);
+```
+
+이제 불필요한 렌더링을 반복하지 않습니다.
+
+{{{example url="../threejs-render-on-demand.html" }}}
+
+`OrbitControls`에는 관성(inertia) 옵션이 있습니다. `enableDamping` 속성을 ture로
+설정하면 동작이 좀 더 부드러워지죠.
+
+※ damping: 감쇠.
+
+```js
+controls.enableDamping = true;
+```
+
+또한 `OrbitControls`가 부드러운 동작을 구현할 때 변경된 카메라 값을 계속 넘겨주도록
+`render` 함수 안에서 `controls.update` 메서드를 호출해야 합니다. 하지만 이렇게 하면
+`change` 이벤트가 발생했을 때 `render` 함수가 무한정 호출될 겁니다. controls가 `change`
+이벤트를 보내면 `render` 함수가 호출되고, `render` 함수는 `controls.update` 메서드를
+호출해 다시 `change` 이벤트를 보내게 만들 테니까요.
+
+`requestAnimationFrame`이 직접 `render` 함수를 호출하게 하면 이 문제를 해결 할 수
+있습니다. 너무 많은 프레임을 막기 위해 변수 하나를 두어 요청한 프레임이 없을 경우에만
+프레임을 요청하도록 하면 되겠네요.
+
+```js
++let renderRequested = false;
+
+function render() {
++  renderRequested = false;
+
+  if (resizeRendererToDisplaySize(renderer)) {
+    const canvas = renderer.domElement;
+    camera.aspect = canvas.clientWidth / canvas.clientHeight;
+    camera.updateProjectionMatrix();
+  }
+
++ controls.update();
+  renderer.render(scene, camera);
+}
+render();
+
++function requestRenderIfNotRequested() {
++  if (!renderRequested) {
++    renderRequested = true;
++    requestAnimationFrame(render);
++  }
++}
+
+-controls.addEventListener('change', render);
++controls.addEventListener('change', requestRenderIfNotRequested);
+```
+
+창 크기 변화가 일어났을 때도 `requestRenderIfNotRequested`를 호출하도록 합니다.
+
+```js
+-window.addEventListener('resize', render);
++window.addEventListener('resize', requestRenderIfNotRequested);
+```
+
+차이점을 느끼기 어려울지도 모르겠습니다. 화살표 키를 쓰거나 예제를 드래그해 보고
+다시 위 예제를 이리저리 돌려보세요. 차이점이 느껴질 거예요. 위 예제는 화살표 키를
+눌렀을 때 일정 거리만큼 순간이동하지만 아래의 예제는 약간 미끄러집니다.
+
+{{{example url="../threejs-render-on-demand-w-damping.html" }}}
+
+간단한 dat.GUI를 추가해 반복 렌더링 여부를 제어할 수 있도록 하겠습니다.
+
+```js
+import * as THREE from './resources/three/r115/build/three.module.js';
+import { OrbitControls } from './resources/threejs/r115/examples/jsm/controls/OrbitControls.js';
++import { GUI } from '../3rdparty/dat.gui.module.js';
+```
+
+먼저 각 정육면체의 색과 x축 스케일을 조정하는 GUI를 추가합니다. [조명에 관한 글](threejs-lights.html)에서
+썼던 `ColorGUIHelper`를 가져와 쓰도록 하죠.
+
+먼저 GUI를 생성합니다.
+
+```js
+const gui = new GUI();
+```
+
+그리고 각 정육면체에 `material.color`, `cube.scale.x` 설정을 폴더로 묶어
+추가합니다.
+
+```js
+function makeInstance(geometry, color, x) {
+  const material = new THREE.MeshPhongMaterial({color});
+
+  const cube = new THREE.Mesh(geometry, material);
+  scene.add(cube);
+
+  cube.position.x = x;
+
++  const folder = gui.addFolder(`Cube${ x }`);
++  folder.addColor(new ColorGUIHelper(material, 'color'), 'value')
++      .name('color')
++      .onChange(requestRenderIfNotRequested);
++  folder.add(cube.scale, 'x', .1, 1.5)
++      .name('scale x')
++      .onChange(requestRenderIfNotRequested);
++  folder.open();
+
+  return cube;
+}
+```
+
+dat.GUI 컨트롤(control)의 `onChange` 메서드에 콜백 함수를 넘겨주면 GUI 값이 바뀔
+때마다 콜백 함수를 호출합니다. 예제의 경우에는 단순히 `requestRenderIfNotRequested`
+함수를 넘겨주면 되죠. 그리고 `folder.open` 메서드를 호출해 폴더를 열어 둡니다.
+
+{{{example url="../threejs-render-on-demand-w-gui.html" }}}
+
+이 글이 불필요한 렌더링 제거에 대한 개념을 조금이라도 잡아주었길 바랍니다. 보통
+Three.js를 사용할 때는 이렇게 렌더링 루프를 제어할 일이 없습니다. 대게 게임 또는
+애니메이션이 들어간 3D 컨텐츠이기 때문이죠. 하지만 지도나, 3D 에디터, 3D 그래프,
+상품 목록 등에서는 이런 기법이 필요할 수도 있습니다.

+ 396 - 0
threejs/lessons/kr/threejs-transparency.md

@@ -0,0 +1,396 @@
+Title: Three.js 투명도
+Description: Three.js에서 투명도를 설정하는 법을 알아봅니다
+TOC: 물체의 투명도 설정하기
+
+Three.js에서 투명도는 간단하지만 동시에 까다로운 주제입니다.
+
+먼저 쉬운 것부터 알아보죠. 예제로 정육면체 8개를 2x2x2 그리드에 맞춘 장면을
+만들어보겠습니다.
+
+[불필요한 렌더링 제거하기](threejs-rendering-on-demand.html)에서 썼던
+예제를 가져와 정육면체 3개를 8개로 수정합니다. 먼저 `makeInstance` 함수가
+x, y, z 값을 받도록 수정하겠습니다.
+
+```js
+-function makeInstance(geometry, color) {
++function makeInstance(geometry, color, x, y, z) {
+  const material = new THREE.MeshPhongMaterial({color});
+
+  const cube = new THREE.Mesh(geometry, material);
+  scene.add(cube);
+
+-  cube.position.x = x;
++  cube.position.set(x, y, z);
+
+  return cube;
+}
+```
+
+그리고 정육면체 8개를 만듭니다.
+
+```js
++function hsl(h, s, l) {
++  return (new THREE.Color()).setHSL(h, s, l);
++}
+
+-makeInstance(geometry, 0x44aa88,  0);
+-makeInstance(geometry, 0x8844aa, -2);
+-makeInstance(geometry, 0xaa8844,  2);
+
++{
++  const d = 0.8;
++  makeInstance(geometry, hsl(0 / 8, 1, .5), -d, -d, -d);
++  makeInstance(geometry, hsl(1 / 8, 1, .5),  d, -d, -d);
++  makeInstance(geometry, hsl(2 / 8, 1, .5), -d,  d, -d);
++  makeInstance(geometry, hsl(3 / 8, 1, .5),  d,  d, -d);
++  makeInstance(geometry, hsl(4 / 8, 1, .5), -d, -d,  d);
++  makeInstance(geometry, hsl(5 / 8, 1, .5),  d, -d,  d);
++  makeInstance(geometry, hsl(6 / 8, 1, .5), -d,  d,  d);
++  makeInstance(geometry, hsl(7 / 8, 1, .5),  d,  d,  d);
++}
+```
+
+카메라도 조정합니다.
+
+```js
+const fov = 75;
+const aspect = 2;  // canvas 기본값
+const near = 0.1;
+-const far = 5;
++const far = 25;
+const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
+-camera.position.z = 4;
++camera.position.z = 2;
+```
+
+배경색은 하얀색으로 바꿔주고
+
+```js
+const scene = new THREE.Scene();
++scene.background = new THREE.Color('white');
+```
+
+정육면체의 옆면도 빛을 받도록 조명을 하나 더 추가합니다.
+
+```js
+-{
++function addLight(...pos) {
+  const color = 0xFFFFFF;
+  const intensity = 1;
+  const light = new THREE.DirectionalLight(color, intensity);
+-  light.position.set(-1, 2, 4);
++  light.position.set(...pos);
+  scene.add(light);
+}
++addLight(-1, 2, 4);
++addLight( 1, -1, -2);
+```
+
+정육면체를 투명하게 만들려면 [`transparent`](Material.transparent) 속성을
+켜고 [`opacity`](Material.opacity) 속성을 설정해줘야 합니다(CSS와 마찬가지로
+0은 완전히 투명함, 1은 완전히 불투명함을 의미).
+
+```js
+function makeInstance(geometry, color, x, y, z) {
+-  const material = new THREE.MeshPhongMaterial({color});
++  const material = new THREE.MeshPhongMaterial({
++    color,
++    opacity: 0.5,
++    transparent: true,
++  });
+
+  const cube = new THREE.Mesh(geometry, material);
+  scene.add(cube);
+
+  cube.position.set(x, y, z);
+
+  return cube;
+}
+```
+
+이제 8개의 반투명한 정육면체가 생겼습니다.
+
+{{{example url="../threejs-transparency.html"}}}
+
+예제를 드래그하면 화면을 회전시킬 수 있습니다.
+
+완벽한데요, 라고 생각했다면 좀 더 자세히 보세요. 정육면체의 뒷면이 하나도
+보이지 않습니다.
+
+<div class="threejs_center"><img src="resources/images/transparency-cubes-no-backs.png" style="width: 416px;"></div>
+<div class="threejs_center">뒷면이 보이지 않는다</div>
+
+이전에 [재질(material)에 관해](threejs-materials.html) 배울 때 [`side`](Material.side)
+속성에 대해 배웠었죠. 이 속성을 `THREE.DoubleSide`로 설정해 정육면체의
+양면이 모두 보이도록 해봅시다.
+
+```js
+const material = new THREE.MeshPhongMaterial({
+  color,
+  map: loader.load(url),
+  opacity: 0.5,
+  transparent: true,
++  side: THREE.DoubleSide,
+});
+```
+
+{{{example url="../threejs-transparency-doubleside.html" }}}
+
+예제를 돌려보세요. 뭔가 해결된 듯 하지만 자세히 보면 가끔 뒷면 또는 뒷면의
+일부가 보이지 않습니다.
+
+<div class="threejs_center"><img src="resources/images/transparency-cubes-some-backs.png" style="width: 368px;"></div>
+<div class="threejs_center">정육면체의 왼쪽 뒷면이 보이지 않는다</div>
+
+이는 3D 요소를 렌더링하는 방식 때문입니다. WebGL은 각 geometry의 삼각형을
+한 번에 하나씩 렌더링합니다. 그리고 삼각형의 픽셀 하나를 렌더링할 때마다
+2개의 정보를 기록하는데, 하나는 해당 픽셀의 색이고 다른 하나는 픽셀의
+깊이(depth)입니다. 다음 삼각형을 그릴 때 해당 픽셀이 이미 그려진 픽셀보다
+깊이가 깊다면 해당 픽셀을 렌더링하지 않죠.
+
+이는 불투명한 물체에서는 문제가 되지 않았지만 투명한 물체에서는 문제가 됩니다.
+
+이 문제를 해결하려면 투명한 물체를 분류해 뒤에 있는 물체를 앞에 있는 물체보다
+먼저 렌더링해야 합니다. `Mesh` 같은 경우는 Three.js가 자동으로 이를 처리해주죠.
+만약 그러지 않았다면 제일 첫 번째 예제에서 뒤에 있는 정육면체를 아예 볼 수
+없었을 겁니다.
+
+정육면체에는 한 면에 2개, 총 12개의 삼각형이 있습니다. 각 삼각형의 렌더링 순서는
+[geometry에 관한 글에서 봤던 것](threejs-custom-buffergeometry.html)과 같죠.
+시선에 따라 카메라에서 가까운 삼각형을 먼저 렌더링할 겁니다. 앞면을 뒷면보다 먼저
+렌더링하니, 때때로 뒷면이 보이지 않을 수밖에 없죠.
+
+구체나 정육면체 등 볼록 물체(convex object)의 경우, 모든 물체를 한 번씩 더 렌더링해
+문제를 해결할 수 있습니다. 하나는 안쪽면 삼각형만 렌더링하고, 다른 하나는 바깥쪽
+삼각형만 렌더링하도록 만드는 것이죠.
+
+```js
+function makeInstance(geometry, color, x, y, z) {
++  [THREE.BackSide, THREE.FrontSide].forEach((side) => {
+    const material = new THREE.MeshPhongMaterial({
+      color,
+      opacity: 0.5,
+      transparent: true,
++      side,
+    });
+
+    const cube = new THREE.Mesh(geometry, material);
+    scene.add(cube);
+
+    cube.position.set(x, y, z);
++  });
+}
+```
+
+어찌어찌 해결된 *것처럼* 보입니다.
+
+{{{example url="../threejs-transparency-doubleside-hack.html" }}}
+
+Three.js의 분류 기준은 고정적인 듯합니다. `side: THREE.BackSide` mesh를 먼저
+넣고, 그 다음 정확히 같은 위치에 `side: THREE.FrontSide` mesh를 넣었으니까요.
+
+이번에는 평면 2개를 교차로 배치해봅시다(정육면체 관련 코드를 전부 지운 뒤).
+각 평면에는 다른 텍스처를 넣을 겁니다.
+
+```js
+const planeWidth = 1;
+const planeHeight = 1;
+const geometry = new THREE.PlaneBufferGeometry(planeWidth, planeHeight);
+
+const loader = new THREE.TextureLoader();
+
+function makeInstance(geometry, color, rotY, url) {
+  const texture = loader.load(url, render);
+  const material = new THREE.MeshPhongMaterial({
+    color,
+    map: texture,
+    opacity: 0.5,
+    transparent: true,
+    side: THREE.DoubleSide,
+  });
+
+  const mesh = new THREE.Mesh(geometry, material);
+  scene.add(mesh);
+
+  mesh.rotation.y = rotY;
+}
+
+makeInstance(geometry, 'pink',       0,             'resources/images/happyface.png');
+makeInstance(geometry, 'lightblue',  Math.PI * 0.5, 'resources/images/hmmmface.png');
+```
+
+평면은 한 번에 한 면밖에 보지 못하니, `side: THREE.DoubleSide`로 설정했습니다. 또한
+텍스처를 전부 불러왔을 때 장면을 다시 렌더링하도록 `render` 함수를 `loader.load`
+메서드에 넘겨줬습니다. 이는 [필요에 따른 렌더링](threejs-rendering-on-demand.html)을
+구현하기 위한 것이죠.
+
+{{{example url="../threejs-transparency-intersecting-planes.html"}}}
+
+아까와 비슷한 문제가 보입니다.
+
+<div class="threejs_center"><img src="resources/images/transparency-planes.png" style="width: 408px;"></div>
+<div class="threejs_center">면의 반쪽이 사라졌다</div>
+
+평면을 둘로 쪼개 실제로는 교차하지 않게끔 만들면 문제를 해결할 수 있습니다.
+
+```js
+function makeInstance(geometry, color, rotY, url) {
++  const base = new THREE.Object3D();
++  scene.add(base);
++  base.rotation.y = rotY;
+
++  [-1, 1].forEach((x) => {
+    const texture = loader.load(url, render);
++    texture.offset.x = x < 0 ? 0 : 0.5;
++    texture.repeat.x = .5;
+    const material = new THREE.MeshPhongMaterial({
+      color,
+      map: texture,
+      opacity: 0.5,
+      transparent: true,
+      side: THREE.DoubleSide,
+    });
+
+    const mesh = new THREE.Mesh(geometry, material);
+-    scene.add(mesh);
++    base.add(mesh);
+
+-    mesh.rotation.y = rotY;
++    mesh.position.x = x * .25;
+  });
+}
+```
+
+저걸 어떻게 구현할지는 여러분의 선택입니다. [블렌더(Blender)](https://blender.org)
+같은 3D 에디터를 사용했다면 텍스처 좌표를 직접 수정했겠죠. 예제의 경우에는
+`PlaneBufferGeometry`를 썼습니다. [이전에 다뤘듯](threejs-textures.html)
+이 geometry는 기본적으로 텍스처를 크기에 맞춰 늘립니다. [`texture.repeat`](Texture.repeat)
+속성과 [`texture.offset`](Texture.offset) 속성을 조정해 각 면에 적절한
+텍스처를 입혀줄 수 있죠.
+
+위 코드에서는 `Object3D`를 만들어 두 평면의 부모로 지정했습니다. 이렇게
+하면 복잡한 계산 없이 간단하게 `Object3D`만 돌려서 두 평면 다 회전시킬
+수 있죠.
+
+{{{example url="../threejs-transparency-intersecting-planes-fixed.html"}}}
+
+이 방법은 교차점이 변하지 않는 정말 간단한 경우에만 가능합니다.
+
+텍스처가 들어간 요소는 알파 테스트(alpha test)를 활성화해 이를 해결할 수
+있죠.
+
+알파 테스트란 Three.js가 픽셀을 렌더링하지 않는 특정 *알파* 단계를 의미합니다.
+만약 아무것도 그리지 않게 설정한다면 위와 같은 문제는 사라지겠죠. 상대적으로
+경계가 분명한 텍스처, 나뭇잎, 잔디 등의 경우 이는 꽤 잘 작동합니다.
+
+이번에도 2개의 면을 만들어 테스트해보도록 합시다. 아까는 텍스처가 전부 불투명했죠.
+이번에는 각 면에는 각각 다른, 부분적으로 투명한 텍스처를 사용할 겁니다.
+
+<div class="spread">
+  <div><img class="checkerboard" src="../resources/images/tree-01.png"></div>
+  <div><img class="checkerboard" src="../resources/images/tree-02.png"></div>
+</div>
+
+아까 평면 2개를 교차해놓았던(반으로 가르기 전) 예제를 가져와 이 텍스처에
+[`alphaTest`](Material.alphaTest) 속성을 지정하겠습니다.
+
+```js
+function makeInstance(geometry, color, rotY, url) {
+  const texture = loader.load(url, render);
+  const material = new THREE.MeshPhongMaterial({
+    color,
+    map: texture,
+-    opacity: 0.5,
+    transparent: true,
++    alphaTest: 0.5,
+    side: THREE.DoubleSide,
+  });
+
+  const mesh = new THREE.Mesh(geometry, material);
+  scene.add(mesh);
+
+  mesh.rotation.y = rotY;
+}
+
+-makeInstance(geometry, 'pink',       0,             'resources/images/happyface.png');
+-makeInstance(geometry, 'lightblue',  Math.PI * 0.5, 'resources/images/hmmmface.png');
++makeInstance(geometry, 'white', 0,             'resources/images/tree-01.png');
++makeInstance(geometry, 'white', Math.PI * 0.5, 'resources/images/tree-02.png');
+```
+
+이대로 실행해도 되지만, 간단한 UI를 만들어 `alphaTest`와 `transparent` 속성을
+갖고 놀 수 있게 해보겠습니다. [씬 그래프에 관한 글](threejs-scenegraph.html)에서
+소개했던 dat.GUI를 써서요.
+
+먼저 dat.GUI에 지정할 헬퍼 클래스를 만들겠습니다. 이 헬퍼 클래스는 장면 안 모든
+재질을 해당 값으로 변경할 겁니다.
+
+```js
+class AllMaterialPropertyGUIHelper {
+  constructor(prop, scene) {
+    this.prop = prop;
+    this.scene = scene;
+  }
+  get value() {
+    const { scene, prop } = this;
+    let v;
+    scene.traverse((obj) => {
+      if (obj.material && obj.material[prop] !== undefined) {
+        v = obj.material[prop];
+      }
+    });
+    return v;
+  }
+  set value(v) {
+    const { scene, prop } = this;
+    scene.traverse((obj) => {
+      if (obj.material && obj.material[prop] !== undefined) {
+        obj.material[prop] = v;
+        obj.material.needsUpdate = true;
+      }
+    });
+  }
+}
+```
+
+다음으로 GUI를 추가합니다.
+
+```js
+const gui = new GUI();
+gui.add(new AllMaterialPropertyGUIHelper('alphaTest', scene), 'value', 0, 1)
+    .name('alphaTest')
+    .onChange(requestRenderIfNotRequested);
+gui.add(new AllMaterialPropertyGUIHelper('transparent', scene), 'value')
+    .name('transparent')
+    .onChange(requestRenderIfNotRequested);
+```
+
+물론 dat.GUI 모듈도 불러와야죠.
+
+```js
+import * as THREE from './resources/three/r115/build/three.module.js';
+import { OrbitControls } from './resources/threejs/r115/examples/jsm/controls/OrbitControls.js';
++import { GUI } from '../3rdparty/dat.gui.module.js';
+```
+
+{{{example url="../threejs-transparency-intersecting-planes-alphatest.html"}}}
+
+예제를 확대해보면 평면에 하얀 테두리가 보일 겁니다.
+
+<div class="threejs_center"><img src="resources/images/transparency-alphatest-issues.png" style="width: 532px;"></div>
+
+이는 앞서 봤던 예제와 같은 문제입니다. 하얀 테두리를 가진 요소가 먼저 그려져
+뒤에 있는 요소가 일부 가려진 것이죠. 완벽한 해결책은 없습니다. 그때그때 상황에
+따라 `alphaTest`나 `transparent` 옵션을 조정해서 상황에 맞는 해결책을 찾아야
+하죠.
+
+결국 이 글의 주제는 "완벽한 투명도는 구현하기 힘들다"가 되겠네요. 웬만한 방법에는
+모두 문제와, 타협점, 편법 등이 존재합니다.
+
+자동차의 경우를 예로 들어보죠. 자동차는 보통 4면에 유리창이 있습니다. 렌더링 순서를
+제대로 적용하려면 각각의 창문을 별도의 요소로 만들어야 합니다. 만약 하나의 요소라면
+Three.js가 렌더링 순서를 제대로 결정할 수 없겠죠.
+
+식물이나 잔디 등을 구현할 때는 일반적으로 알파 테스트를 사용합니다.
+
+어떤 방법을 사용할지는 전적으로 상황과 여러분의 판단에 달려 있죠.