indexed-textures.html 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541
  1. <!DOCTYPE html><html lang="ko"><head>
  2. <meta charset="utf-8">
  3. <title>피킹과 색상에 인덱스 텍스처 사용하기</title>
  4. <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
  5. <meta name="twitter:card" content="summary_large_image">
  6. <meta name="twitter:site" content="@threejs">
  7. <meta name="twitter:title" content="Three.js – 피킹과 색상에 인덱스 텍스처 사용하기">
  8. <meta property="og:image" content="https://threejs.org/files/share.png">
  9. <link rel="shortcut icon" href="../../files/favicon_white.ico" media="(prefers-color-scheme: dark)">
  10. <link rel="shortcut icon" href="../../files/favicon.ico" media="(prefers-color-scheme: light)">
  11. <link rel="stylesheet" href="../resources/lesson.css">
  12. <link rel="stylesheet" href="../resources/lang.css">
  13. <script type="importmap">
  14. {
  15. "imports": {
  16. "three": "../../build/three.module.js"
  17. }
  18. }
  19. </script>
  20. <link rel="stylesheet" href="/manual/ko/lang.css">
  21. </head>
  22. <body>
  23. <div class="container">
  24. <div class="lesson-title">
  25. <h1>피킹과 색상에 인덱스 텍스처 사용하기</h1>
  26. </div>
  27. <div class="lesson">
  28. <div class="lesson-main">
  29. <p>※ 이 글은 <a href="align-html-elements-to-3d.html">HTML 요소를 3D로 정렬하기</a>에서 이어집니다. 이전 글을 읽지 않았다면 먼저 읽고 오기 바랍니다.</p>
  30. <p>Three.js를 쓰다보면 창의적인 해결법이 필요할 때가 있습니다. 저도 나름 시리즈를 진행하며 나름 많은 해결법을 찾고, 적어 놓았죠. 혹 필요한 게 있다면 확인해보기 바랍니다. 물론 그게 최적의 해결법이라고 단언할 수는 없지만요.</p>
  31. <p><a href="align-html-elements-to-3d.html">이전 글</a>에서는 3D 지구본 주위에 나라 이름을 표기했습니다. 여기서 더 나아가 사용자가 나라를 선택하고 자기가 선택한 나라를 보게 한다면 어떨까요? 또 어떻게 구현할 수 있을까요?</p>
  32. <p>가장 쉽게 떠오르는 방법은 각 나라마다 geometry를 만드는 겁니다. 이전에 했던 것처럼 <a href="picking.html">피킹(picking)</a>을 써서 구현할 수 있겠죠. 이미 각 나라의 3D geometry는 만들었으니 사용자가 mesh를 클릭했을 때 어떤 나라를 클릭했는지 알 수 있을 겁니다.</p>
  33. <p>시험삼아 <a href="align-html-elements-to-3d.html">이전 글</a>에서 윤곽선을 만들기 위해 사용했던 데이터로 각 나라마다 3D mesh를 만들어봤습니다. 결과로 15.5MB짜리 GLTF(.glb) 파일이 나왔죠. 사용자가 간단한 지구본을 보려고 15.5MB나 다운 받아야 한다니, 개인적인 의견이지만 너무 과한 듯합니다.</p>
  34. <p>데이터를 압축할 수 있는 방법이야 많습니다. 예를 들어 특정 알고리즘을 도입해 윤곽선의 해상도를 낮출 수 있죠. 이 글에서는 시도하지 않을 텐데, 이유는 미국의 경우 데이터를 많이 줄일 수 있겠지만 캐나다나 섬이 많은 나라는 그렇지 않을 것이기 때문입니다.</p>
  35. <p>다른 방법은 실제 데이터를 전부 압축하는 겁니다. 압축 프로그램을 돌려 압축하니 용량이 11MB까지 줄더군요. 30% 정도 줄긴 했지만 여전히 큰 파일입니다.</p>
  36. <p>32비트 부동 소수 대신 16비트 방식으로 데이터를 저장할 수도 있습니다. 또는 <a href="https://google.github.io/draco/">드레이코 압축기</a> 같은 프로그램을 사용하는 것만으로 충분히 데이터를 줄일 수 있을지도 모르죠. 전 따로 드레이코 압축기를 사용해보진 않았으니 여러분이 한 번 써보시고 알려주신다면 감사하겠습니다 😅.</p>
  37. <p>이 글에서는 <a href="picking.html">피킹에 관한 글</a> 마지막에서 다뤘던 <a href="picking.html">GPU 피킹</a>을 사용해보겠습니다. 각 mesh에 id 역할을 할 고유한 색을 부여하고 해당 mesh를 클릭했을 때 해당 픽셀의 색상값으로 사용자가 어떤 mesh를 클릭했는지 알아내는 방법이죠.</p>
  38. <p>일단 각 나라에 고유한 색상을 부여한 뒤, 이 색상값을 인덱스로 나라 배열을 만듭니다. 그리고 피킹용 텍스처를 만든 뒤 이걸로 지구본을 렌더링합니다. 이러면 사용자가 클릭한 픽셀을 확인해 어떤 나라를 클릭했는지 알 수 있겠죠.</p>
  39. <p>먼저 <a href="https://github.com/mrdoob/three.js/blob/master/manual/resources/tools/geo-picking/">약간의 코드</a>를 작성해 아래의 텍스처를 만들었습니다.</p>
  40. <div class="threejs_center"><img src="../examples/resources/data/world/country-index-texture.png" style="width: 700px;"></div>
  41. <blockquote>
  42. <p>참고: 이 텍스트를 만드는 데 사용한 데이터는 이 <a href="http://thematicmapping.org/downloads/world_borders.php">웹사이트</a>이며, 라이선스는 <a href="http://creativecommons.org/licenses/by-sa/3.0/">CC-BY-SA</a>입니다.</p>
  43. </blockquote>
  44. <p>이 이미지는 271KB 정도밖에 되지 않습니다. 나라들의 mesh가 14MB가 넘었던 것에 비하면 훨씬 낫네요. 물론 해상도를 더 낮출 수도 있지만 이 정도면 충분한 것 같네요.</p>
  45. <p>이제 나라에 피킹을 적용해 봅시다.</p>
  46. <p><a href="picking.html">GPU 피킹 예제</a>의 코드를 가져와 피킹용 장면(scene)을 따로 만듭니다.</p>
  47. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const pickingScene = new THREE.Scene();
  48. pickingScene.background = new THREE.Color(0);
  49. </pre>
  50. <p>피킹용 장면에 피킹용 텍스처를 입힌 지구본을 추가합니다.</p>
  51. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">{
  52. const loader = new THREE.TextureLoader();
  53. const geometry = new THREE.SphereGeometry(1, 64, 32);
  54. + const indexTexture = loader.load('resources/data/world/country-index-texture.png', render);
  55. + indexTexture.minFilter = THREE.NearestFilter;
  56. + indexTexture.magFilter = THREE.NearestFilter;
  57. +
  58. + const pickingMaterial = new THREE.MeshBasicMaterial({ map: indexTexture });
  59. + pickingScene.add(new THREE.Mesh(geometry, pickingMaterial));
  60. const texture = loader.load('resources/data/world/country-outlines-4k.png', render);
  61. const material = new THREE.MeshBasicMaterial({ map: texture });
  62. scene.add(new THREE.Mesh(geometry, material));
  63. }
  64. </pre>
  65. <p>다음으로 <code class="notranslate" translate="no">GPUPickHelper</code>를 통째로 가져와 몇 가지 수정합니다.</p>
  66. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">class GPUPickHelper {
  67. constructor() {
  68. // 1x1 픽셀 크기의 렌더 타겟을 생성합니다
  69. this.pickingTexture = new THREE.WebGLRenderTarget(1, 1);
  70. this.pixelBuffer = new Uint8Array(4);
  71. - this.pickedObject = null;
  72. - this.pickedObjectSavedColor = 0;
  73. }
  74. pick(cssPosition, scene, camera) {
  75. const { pickingTexture, pixelBuffer } = this;
  76. // view offset을 마우스 포인터 아래 1픽셀로 설정합니다
  77. const pixelRatio = renderer.getPixelRatio();
  78. camera.setViewOffset(
  79. renderer.getContext().drawingBufferWidth, // 전체 너비
  80. renderer.getContext().drawingBufferHeight, // 전체 높이
  81. cssPosition.x * pixelRatio | 0, // 사각 x 좌표
  82. cssPosition.y * pixelRatio | 0, // 사각 y 좌표
  83. 1, // 사각 좌표 width
  84. 1, // 사각 좌표 height
  85. );
  86. // 장면을 렌더링합니다
  87. renderer.setRenderTarget(pickingTexture);
  88. renderer.render(scene, camera);
  89. renderer.setRenderTarget(null);
  90. // view offset을 정상으로 돌려 원래의 화면을 렌더링하도록 합니다
  91. camera.clearViewOffset();
  92. // 픽셀을 감지합니다
  93. renderer.readRenderTargetPixels(
  94. pickingTexture,
  95. 0, // x
  96. 0, // y
  97. 1, // width
  98. 1, // height
  99. pixelBuffer);
  100. + const id =
  101. + (pixelBuffer[0] &lt;&lt; 16) |
  102. + (pixelBuffer[1] &lt;&lt; 8) |
  103. + (pixelBuffer[2] &lt;&lt; 0);
  104. +
  105. + return id;
  106. - const id =
  107. - (pixelBuffer[0] &lt;&lt; 16) |
  108. - (pixelBuffer[1] &lt;&lt; 8) |
  109. - (pixelBuffer[2] );
  110. - const intersectedObject = idToObject[id];
  111. - if (intersectedObject) {
  112. - // 첫 번째 물체가 제일 가까우므로 해당 물체를 고릅니다
  113. - this.pickedObject = intersectedObject;
  114. - // 기존 색을 저장해둡니다
  115. - this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
  116. - // emissive 색을 빨강/노랑으로 빛나게 만듭니다
  117. - this.pickedObject.material.emissive.setHex((time * 8) % 2 &gt; 1 ? 0xFFFF00 : 0xFF0000);
  118. - }
  119. }
  120. }
  121. </pre>
  122. <p><code class="notranslate" translate="no">GPUPickHelper</code>를 이용해 나라를 선택하도록 합니다.</p>
  123. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const pickHelper = new GPUPickHelper();
  124. function getCanvasRelativePosition(event) {
  125. const rect = canvas.getBoundingClientRect();
  126. return {
  127. x: (event.clientX - rect.left) * canvas.width / rect.width,
  128. y: (event.clientY - rect.top ) * canvas.height / rect.height,
  129. };
  130. }
  131. function pickCountry(event) {
  132. // 아직 데이터를 불러오지 않았을 경우
  133. if (!countryInfos) {
  134. return;
  135. }
  136. const position = getCanvasRelativePosition(event);
  137. const id = pickHelper.pick(position, pickingScene, camera);
  138. if (id &gt; 0) {
  139. // 나라를 선택했을 때 해당 나라의 'selected' 속성을 바꿉니다.
  140. const countryInfo = countryInfos[id - 1];
  141. const selected = !countryInfo.selected;
  142. // 나라를 클릭했을 때 특수키를 누르지 않았다면 다른 나라의 'selected'
  143. // 속성을 전부 끕니다.
  144. if (selected &amp;&amp; !event.shiftKey &amp;&amp; !event.ctrlKey &amp;&amp; !event.metaKey) {
  145. unselectAllCountries();
  146. }
  147. numCountriesSelected += selected ? 1 : -1;
  148. countryInfo.selected = selected;
  149. } else if (numCountriesSelected) {
  150. // 바다나 하늘을 클릭했을 경우
  151. unselectAllCountries();
  152. }
  153. requestRenderIfNotRequested();
  154. }
  155. function unselectAllCountries() {
  156. numCountriesSelected = 0;
  157. countryInfos.forEach((countryInfo) =&gt; {
  158. countryInfo.selected = false;
  159. });
  160. }
  161. canvas.addEventListener('pointerup', pickCountry);
  162. </pre>
  163. <p>위 코드는 나라 배열에 속한 나라의 <code class="notranslate" translate="no">selected</code> 속성을 켜고 끕니다. <code class="notranslate" translate="no">shift</code>, <code class="notranslate" translate="no">ctrl</code>, <code class="notranslate" translate="no">cmd</code> 중 하나를 누르면 하나 이상의 나라를 선택할 수 있죠.</p>
  164. <p>이제 선택한 나라를 보여줄 일만 남았습니다. 지금은 일단 해당 나라의 이름표를 보여주기로 하죠.</p>
  165. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">function updateLabels() {
  166. // 아직 데이터를 불러오지 않았을 경우
  167. if (!countryInfos) {
  168. return;
  169. }
  170. const large = settings.minArea * settings.minArea;
  171. // 카메라의 상대 방향을 나타내는 행렬 좌표를 가져옵니다.
  172. normalMatrix.getNormalMatrix(camera.matrixWorldInverse);
  173. // 카메라의 위치를 가져옵니다.
  174. camera.getWorldPosition(cameraPosition);
  175. for (const countryInfo of countryInfos) {
  176. - const { position, elem, area } = countryInfo;
  177. - // 영역이 특정 값보다 작다면 이름표를 표시하지 않습니다.
  178. - if (area &lt; large) {
  179. + const { position, elem, area, selected } = countryInfo;
  180. + const largeEnough = area &gt;= large;
  181. + const show = selected || (numCountriesSelected === 0 &amp;&amp; largeEnough);
  182. + if (!show) {
  183. elem.style.display = 'none';
  184. continue;
  185. }
  186. ...
  187. </pre>
  188. <p>이제 나라를 선택해 볼 수 있습니다.</p>
  189. <p></p><div translate="no" class="threejs_example_container notranslate">
  190. <div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/indexed-textures-picking.html"></iframe></div>
  191. <a class="threejs_center" href="/manual/examples/indexed-textures-picking.html" target="_blank">새 탭에서 보기</a>
  192. </div>
  193. <p></p>
  194. <p>위 예제는 여전히 영역 크기에 따라 나라 이름을 보여주긴 하나, 특정 나라를 클릭하면 해당 나라의 이름만 보여줄 겁니다.</p>
  195. <p>이만하면 각 나라를 피킹하는 예제로 충분해 보이지만... 선택한 나라의 색을 바꾸려면 어떻게 해야 할까요?</p>
  196. <p><em>컬러 팔레트(color palette)</em>를 사용하면 이 문제를 해결할 수 있습니다.</p>
  197. <p><a href="https://ko.wikipedia.org/wiki/%ED%8C%94%EB%A0%88%ED%8A%B8_(%EC%BB%B4%ED%93%A8%ED%8C%85">컬러 팔레트</a>) 혹은 <a href="https://en.wikipedia.org/wiki/Indexed_color">인덱스 팔레트</a>는 아타리 800, Amiga, NES, 슈퍼 닌텐도, 구형 IBM PC 등 구형 시스템에서 사용하던 기법입니다. 비트맵을 색상당 8비트 혹은 32바이트 이상의 RGBA 색상으로 적용하는 대신 비트맵을 8비트 이하의 값으로 저장하는 기법이죠. 각 픽셀의 색상값은 팔레트의 인덱스 값으로, 픽셀의 색상값이 3이라면 특정 "팔레트"의 3번 색상을 사용한다는 의미입니다.</p>
  198. <p>자바스크립트로 설명하자면 아래와 같은 형식을 생각할 수 있습니다.</p>
  199. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const face7x7PixelImageData = [
  200. 0, 1, 1, 1, 1, 1, 0,
  201. 1, 0, 0, 0, 0, 0, 1,
  202. 1, 0, 2, 0, 2, 0, 1,
  203. 1, 0, 0, 0, 0, 0, 1,
  204. 1, 0, 3, 3, 3, 0, 1,
  205. 1, 0, 0, 0, 0, 0, 1,
  206. 0, 1, 1, 1, 1, 1, 1,
  207. ];
  208. const palette = [
  209. [255, 255, 255], // white
  210. [ 0, 0, 0], // black
  211. [ 0, 255, 255], // cyan
  212. [255, 0, 0], // red
  213. ];
  214. </pre>
  215. <p>이미지 데이터의 각 픽셀은 팔레트의 인덱스를 가리킵니다. 위 데이터를 위 팔레트로 해석하면 다음과 같은 이미지가 나오겠죠.</p>
  216. <div class="threejs_center"><img src="../resources/images/7x7-indexed-face.png"></div>
  217. <p>예제의 경우 이미 각 나라별로 고유 색을 부여한 텍스처가 있습니다. 이 텍스처에 팔레트를 적용하면 각 나라에 다른 색을 부여할 수 있겠죠. 또 이 팔레트의 색을 바꾸면 각 나라의 색도 바뀔 겁니다. 그러니 팔레트의 색을 전부 검정으로 바꾼 뒤, 선택한 나라만 다른 색으로 바꾸면 해당 나라를 선택했다는 것을 시각적으로 나타낼 수 있을 겁니다.</p>
  218. <p>컬러 팔레트 기법을 사용하려면 쉐이더를 직접 만들어야 합니다. Three.js의 내장 쉐이더를 수정해서 사용하면 조명이나 다른 기능도 나중에 사용할 수 있으니 이 방법을 사용하도록 하죠.</p>
  219. <p><a href="optimize-lots-of-objects-animated.html">다중 애니메이션 요소 최적화하기</a>에서 다뤘듯 재질의 <code class="notranslate" translate="no">onBeforeCompile</code> 속성에 함수를 지정하면 내장 쉐이더를 수정할 수 있습니다.</p>
  220. <p>아래는 내장 fragment 쉐이더를 수정하기 전입니다.</p>
  221. <pre class="prettyprint showlinemods notranslate lang-glsl" translate="no">#include &lt;common&gt;
  222. #include &lt;color_pars_fragment&gt;
  223. #include &lt;uv_pars_fragment&gt;
  224. #include &lt;map_pars_fragment&gt;
  225. #include &lt;alphamap_pars_fragment&gt;
  226. #include &lt;aomap_pars_fragment&gt;
  227. #include &lt;lightmap_pars_fragment&gt;
  228. #include &lt;envmap_pars_fragment&gt;
  229. #include &lt;fog_pars_fragment&gt;
  230. #include &lt;specularmap_pars_fragment&gt;
  231. #include &lt;logdepthbuf_pars_fragment&gt;
  232. #include &lt;clipping_planes_pars_fragment&gt;
  233. void main() {
  234. #include &lt;clipping_planes_fragment&gt;
  235. vec4 diffuseColor = vec4( diffuse, opacity );
  236. #include &lt;logdepthbuf_fragment&gt;
  237. #include &lt;map_fragment&gt;
  238. #include &lt;color_fragment&gt;
  239. #include &lt;alphamap_fragment&gt;
  240. #include &lt;alphatest_fragment&gt;
  241. #include &lt;specularmap_fragment&gt;
  242. ReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );
  243. #ifdef USE_LIGHTMAP
  244. reflectedLight.indirectDiffuse += texture2D( lightMap, vLightMapUv ).xyz * lightMapIntensity;
  245. #else
  246. reflectedLight.indirectDiffuse += vec3( 1.0 );
  247. #endif
  248. #include &lt;aomap_fragment&gt;
  249. reflectedLight.indirectDiffuse *= diffuseColor.rgb;
  250. vec3 outgoingLight = reflectedLight.indirectDiffuse;
  251. #include &lt;envmap_fragment&gt;
  252. gl_FragColor = vec4( outgoingLight, diffuseColor.a );
  253. #include &lt;premultiplied_alpha_fragment&gt;
  254. #include &lt;tonemapping_fragment&gt;
  255. #include &lt;colorspace_fragment&gt;
  256. #include &lt;fog_fragment&gt;
  257. }
  258. </pre>
  259. <p><a href="https://github.com/mrdoob/three.js/tree/dev/src/renderers/shaders/ShaderChunk">위 코드의 쉐이더 조각</a>을 일일이 뒤져 보니 Three.js는 <code class="notranslate" translate="no">diffuseColor</code>라는 변수로 재질(material)의 색상값을 제어합니다. 이 변수는 <code class="notranslate" translate="no">&lt;color_fragment&gt;</code>라는 <a href="https://github.com/mrdoob/three.js/blob/dev/src/renderers/shaders/ShaderChunk/color_fragment.glsl.js">쉐이더 조각</a>에서 선언하니 변수 선언 후에 색상값을 수정하면 되겠네요.</p>
  260. <p>저 때 <code class="notranslate" translate="no">diffuseColor</code>는 아까 만들었던 윤곽선 텍스처에서 색상을 가져온 상태일 테니, 이 색상값으로 팔레트 텍스처에서 새로운 색상값을 가져 올 수 있을 겁니다.</p>
  261. <p><a href="optimize-lots-of-objects-animated.html">이전에 했던 것</a>처럼 바꿀 문자열 정보를 배열로 만들어 <a href="/docs/#api/ko/materials/Material.onBeforeCompile"><code class="notranslate" translate="no">Material.onBeforeCompile</code></a>에서 쉐이더를 수정하겠습니다.</p>
  262. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">{
  263. const loader = new THREE.TextureLoader();
  264. const geometry = new THREE.SphereGeometry(1, 64, 32);
  265. const indexTexture = loader.load('resources/data/world/country-index-texture.png', render);
  266. indexTexture.minFilter = THREE.NearestFilter;
  267. indexTexture.magFilter = THREE.NearestFilter;
  268. const pickingMaterial = new THREE.MeshBasicMaterial({ map: indexTexture });
  269. pickingScene.add(new THREE.Mesh(geometry, pickingMaterial));
  270. + const fragmentShaderReplacements = [
  271. + {
  272. + from: '#include &lt;common&gt;',
  273. + to: `
  274. + #include &lt;common&gt;
  275. + uniform sampler2D indexTexture;
  276. + uniform sampler2D paletteTexture;
  277. + uniform float paletteTextureWidth;
  278. + `,
  279. + },
  280. + {
  281. + from: '#include &lt;color_fragment&gt;',
  282. + to: `
  283. + #include &lt;color_fragment&gt;
  284. + {
  285. + vec4 indexColor = texture2D(indexTexture, vUv);
  286. + float index = indexColor.r * 255.0 + indexColor.g * 255.0 * 256.0;
  287. + vec2 paletteUV = vec2((index + 0.5) / paletteTextureWidth, 0.5);
  288. + vec4 paletteColor = texture2D(paletteTexture, paletteUV);
  289. + // diffuseColor.rgb += paletteColor.rgb; // 하얀 윤곽선
  290. + diffuseColor.rgb = paletteColor.rgb - diffuseColor.rgb; // 검은 윤곽선
  291. + }
  292. + `,
  293. + },
  294. + ];
  295. const texture = loader.load('resources/data/world/country-outlines-4k.png', render);
  296. const material = new THREE.MeshBasicMaterial({ map: texture });
  297. + material.onBeforeCompile = function(shader) {
  298. + fragmentShaderReplacements.forEach((rep) =&gt; {
  299. + shader.fragmentShader = shader.fragmentShader.replace(rep.from, rep.to);
  300. + });
  301. + };
  302. scene.add(new THREE.Mesh(geometry, material));
  303. }
  304. </pre>
  305. <p>위 코드에서는 <code class="notranslate" translate="no">indexTexture</code>, <code class="notranslate" translate="no">paletteTexture</code>, <code class="notranslate" translate="no">paletteTextureWidth</code>, 총 3개의 균등 변수(uniform)를 사용했습니다. <code class="notranslate" translate="no">indexTexture</code>는 색상값을 불러와 인덱스로 변환하기 위한 것으로, 이때 사용한 <code class="notranslate" translate="no">vUv</code>는 Three.js가 넘겨주는 텍스처 좌표이죠. 그리고 이 인덱스 값으로 컬러 팔레트에서 새로운 색상값을 가져 와 <code class="notranslate" translate="no">diffuseColor</code>와 섞었습니다. 이때 <code class="notranslate" translate="no">diffuseColor</code>는 검은바탕에 하얀색 윤곽선 텍스처이니 두 색을 더해도 하얀 윤곽선이 나올 겁니다. 대신 새로운 색에서 <code class="notranslate" translate="no">diffuseColor</code>를 뺀다면 검은 윤곽선이 나오겠죠.</p>
  306. <p>다음으로 렌더링 전에 팔레트 텍스처와 3개의 균등 변수를 지정해야 합니다.</p>
  307. <p>팔레트 텍스처에는 나라당 하나의 색상과 바다의 색상(id = 0) 하나만 필요합니다. 전 세계적으로 약 240여 개의 나라가 있죠. 나라 배열을 불러올 때까지 기다렸다가 정확한 개수를 받아올 수도 있을 겁니다. 하지만 당장 숫자가 크다고 문제가 될 것 같지는 않으니 512 정도의 큰 숫자를 고르기로 합시다.</p>
  308. <p>아래는 팔레트 텍스처를 만드는 코드입니다.</p>
  309. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const maxNumCountries = 512;
  310. const paletteTextureWidth = maxNumCountries;
  311. const paletteTextureHeight = 1;
  312. const palette = new Uint8Array(paletteTextureWidth * 4);
  313. const paletteTexture = new THREE.DataTexture(
  314. palette, paletteTextureWidth, paletteTextureHeight);
  315. paletteTexture.minFilter = THREE.NearestFilter;
  316. paletteTexture.magFilter = THREE.NearestFilter;
  317. </pre>
  318. <p><a href="/docs/#api/ko/textures/DataTexture"><code class="notranslate" translate="no">DataTexture</code></a>를 쓰면 텍스처를 로우-데이터(raw data) 형식으로 넘길 수 있습니다. 예제의 경우에는 512 RGBA 색상을 넘겨주면 되겠죠. 각 값은 3바이트로, 이 바이트는 각각 red, green, blue을 0부터 255까지의 숫자로 나타냅니다.</p>
  319. <p>일단은 무작위로 색을 지정해 잘 작동하는지 테스트해봅시다.</p>
  320. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">for (let i = 1; i &lt; palette.length; ++i) {
  321. palette[i] = Math.random() * 256;
  322. }
  323. // 바다의 색을 지정합니다. (index #0)
  324. palette.set([100, 200, 255, 255], 0);
  325. paletteTexture.needsUpdate = true;
  326. </pre>
  327. <p><code class="notranslate" translate="no">palette</code> 배열로 팔레트 텍스처를 업데이트할 때마다 장면을 업데이트해야 하니 <code class="notranslate" translate="no">paletteTexture.needsUpdate</code>를 <code class="notranslate" translate="no">true</code>로 설정합니다.</p>
  328. <p>다음으로 재질에 균등 변수를 설정해줍니다.</p>
  329. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const geometry = new THREE.SphereGeometry(1, 64, 32);
  330. const material = new THREE.MeshBasicMaterial({ map: texture });
  331. material.onBeforeCompile = function(shader) {
  332. fragmentShaderReplacements.forEach((rep) =&gt; {
  333. shader.fragmentShader = shader.fragmentShader.replace(rep.from, rep.to);
  334. });
  335. + shader.uniforms.paletteTexture = { value: paletteTexture };
  336. + shader.uniforms.indexTexture = { value: indexTexture };
  337. + shader.uniforms.paletteTextureWidth = { value: paletteTextureWidth };
  338. };
  339. scene.add(new THREE.Mesh(geometry, material));
  340. </pre>
  341. <p>이제 예제를 실행하면 각 나라의 색상이 무작위로 지정된 것이 보일 겁니다.</p>
  342. <p></p><div translate="no" class="threejs_example_container notranslate">
  343. <div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/indexed-textures-random-colors.html"></iframe></div>
  344. <a class="threejs_center" href="/manual/examples/indexed-textures-random-colors.html" target="_blank">새 탭에서 보기</a>
  345. </div>
  346. <p></p>
  347. <p>인덱싱과 팔레트 텍스처가 잘 작동하는 것을 확인했으니, 이제 팔레트를 조작해 선택한 나라의 색상만 바꾸도록 해봅시다.</p>
  348. <p>먼저 함수를 하나 만듭니다. 이 함수는 Three.js의 <a href="/docs/#api/ko/math/Color"><code class="notranslate" translate="no">Color</code></a>를 매개변수로 받아 팔레트 텍스처에 지정할 수 있는 값을 반환할 겁니다.</p>
  349. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const tempColor = new THREE.Color();
  350. function get255BasedColor(color) {
  351. tempColor.set(color);
  352. const base = tempColor.toArray().map(v =&gt; v * 255);
  353. base.push(255); // alpha
  354. return base;
  355. }
  356. </pre>
  357. <p>위 함수를 <code class="notranslate" translate="no">color = get255BasedColor('red')</code>와 같은 식으로 호출하면 <code class="notranslate" translate="no">[255, 0, 0]</code> 이런 식의 배열을 반환합니다.</p>
  358. <p>다음으로 위 함수를 이용해 몇 가지 색을 만들어 팔레트를 채웁니다.</p>
  359. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const selectedColor = get255BasedColor('red');
  360. const unselectedColor = get255BasedColor('#444');
  361. const oceanColor = get255BasedColor('rgb(100,200,255)');
  362. resetPalette();
  363. function setPaletteColor(index, color) {
  364. palette.set(color, index * 4);
  365. }
  366. function resetPalette() {
  367. // 모든 팔레트의 색상을 unselectedColor로 바꿉니다.
  368. for (let i = 1; i &lt; maxNumCountries; ++i) {
  369. setPaletteColor(i, unselectedColor);
  370. }
  371. // 바다의 색을 지정합니다. (index #0)
  372. setPaletteColor(0, oceanColor);
  373. paletteTexture.needsUpdate = true;
  374. }
  375. </pre>
  376. <p>이제 <code class="notranslate" translate="no">resetPalette</code> 함수를 이용해 나라를 선택했을 때 팔레트를 업데이트합니다.</p>
  377. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">function getCanvasRelativePosition(event) {
  378. const rect = canvas.getBoundingClientRect();
  379. return {
  380. x: (event.clientX - rect.left) * canvas.width / rect.width,
  381. y: (event.clientY - rect.top ) * canvas.height / rect.height,
  382. };
  383. }
  384. function pickCountry(event) {
  385. // 아직 데이터를 불러오지 않았을 경우
  386. if (!countryInfos) {
  387. return;
  388. }
  389. const position = getCanvasRelativePosition(event);
  390. const id = pickHelper.pick(position, pickingScene, camera);
  391. if (id &gt; 0) {
  392. const countryInfo = countryInfos[id - 1];
  393. const selected = !countryInfo.selected;
  394. if (selected &amp;&amp; !event.shiftKey &amp;&amp; !event.ctrlKey &amp;&amp; !event.metaKey) {
  395. unselectAllCountries();
  396. }
  397. numCountriesSelected += selected ? 1 : -1;
  398. countryInfo.selected = selected;
  399. + setPaletteColor(id, selected ? selectedColor : unselectedColor);
  400. + paletteTexture.needsUpdate = true;
  401. } else if (numCountriesSelected) {
  402. unselectAllCountries();
  403. }
  404. requestRenderIfNotRequested();
  405. }
  406. function unselectAllCountries() {
  407. numCountriesSelected = 0;
  408. countryInfos.forEach((countryInfo) =&gt; {
  409. countryInfo.selected = false;
  410. });
  411. + resetPalette();
  412. }
  413. </pre>
  414. <p>이제 선택한 나라가 강조되어 보일 겁니다.</p>
  415. <p></p><div translate="no" class="threejs_example_container notranslate">
  416. <div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/indexed-textures-picking-and-highlighting.html"></iframe></div>
  417. <a class="threejs_center" href="/manual/examples/indexed-textures-picking-and-highlighting.html" target="_blank">새 탭에서 보기</a>
  418. </div>
  419. <p></p>
  420. <p>잘 작동하는 것 같네요!</p>
  421. <p>다만 지구본을 돌릴 때도 나라가 선택된다는 게 거슬립니다. 또 나라를 선택하고 지구본을 돌리면 해당 선택이 풀려버리네요.</p>
  422. <p>마지막으로 이것까지 고쳐봅시다. 2가지 정도를 확인하면 충분할 것 같네요. 하나는 포인터를 누른 후 떼기까지 얼마나 시간이 흘렀는지를 확이하는 것이고, 다른 하나는 포인터가 움직였는지 확인하는 겁니다. 마우스를 떼는 데 시간이 얼마 안 걸렸고 포인터가 움직이지 않았다면 클릭으로 간주하는 것이죠.</p>
  423. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">+const maxClickTimeMs = 200;
  424. +const maxMoveDeltaSq = 5 * 5;
  425. +const startPosition = {};
  426. +let startTimeMs;
  427. +
  428. +function recordStartTimeAndPosition(event) {
  429. + startTimeMs = performance.now();
  430. + const pos = getCanvasRelativePosition(event);
  431. + startPosition.x = pos.x;
  432. + startPosition.y = pos.y;
  433. +}
  434. function getCanvasRelativePosition(event) {
  435. const rect = canvas.getBoundingClientRect();
  436. return {
  437. x: (event.clientX - rect.left) * canvas.width / rect.width,
  438. y: (event.clientY - rect.top ) * canvas.height / rect.height,
  439. };
  440. }
  441. function pickCountry(event) {
  442. // 아직 데이터를 불러오지 않았을 경우
  443. if (!countryInfos) {
  444. return;
  445. }
  446. + // 포인터를 누른 후 떼기까지 일정 시간 이상 걸렸다면
  447. + // 선택 액션이 아닌 드래그 액션으로 간주합니다.
  448. + const clickTimeMs = performance.now() - startTimeMs;
  449. + if (clickTimeMs &gt; maxClickTimeMs) {
  450. + return;
  451. + }
  452. +
  453. + // 포인터가 움직였다면 드래그로 간주합니다.
  454. + const position = getCanvasRelativePosition(event);
  455. + const moveDeltaSq = (startPosition.x - position.x) ** 2 +
  456. + (startPosition.y - position.y) ** 2;
  457. + if (moveDeltaSq &gt; maxMoveDeltaSq) {
  458. + return;
  459. + }
  460. - const position = { x: event.clientX, y: event.clientY };
  461. const id = pickHelper.pick(position, pickingScene, camera);
  462. if (id &gt; 0) {
  463. const countryInfo = countryInfos[id - 1];
  464. const selected = !countryInfo.selected;
  465. if (selected &amp;&amp; !event.shiftKey &amp;&amp; !event.ctrlKey &amp;&amp; !event.metaKey) {
  466. unselectAllCountries();
  467. }
  468. numCountriesSelected += selected ? 1 : -1;
  469. countryInfo.selected = selected;
  470. setPaletteColor(id, selected ? selectedColor : unselectedColor);
  471. paletteTexture.needsUpdate = true;
  472. } else if (numCountriesSelected) {
  473. unselectAllCountries();
  474. }
  475. requestRenderIfNotRequested();
  476. }
  477. function unselectAllCountries() {
  478. numCountriesSelected = 0;
  479. countryInfos.forEach((countryInfo) =&gt; {
  480. countryInfo.selected = false;
  481. });
  482. resetPalette();
  483. }
  484. +canvas.addEventListener('pointerdown', recordStartTimeAndPosition);
  485. canvas.addEventListener('pointerup', pickCountry);
  486. </pre>
  487. <p>제 기준에서는 이 정도면 충분한 <em>듯하네요</em>.</p>
  488. <p></p><div translate="no" class="threejs_example_container notranslate">
  489. <div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/indexed-textures-picking-debounced.html"></iframe></div>
  490. <a class="threejs_center" href="/manual/examples/indexed-textures-picking-debounced.html" target="_blank">새 탭에서 보기</a>
  491. </div>
  492. <p></p>
  493. <p>저는 UX 전문가가 아니니 더 나은 방법이 있을 경우 알려주시면 감사하겠습니다.</p>
  494. <p>이 글이 인덱스(indexed) 그래픽을 활용하고, Three.js의 쉐이더를 수정해 간단한 효과를 구현하는 데 도움이 되었다면 좋겠네요. 쉐이더를 작성할 때 쓴 GLSL에 대해 다루기에는 너무 내용이 방대하니 <a href="post-processing.html">후처리에 관한 글</a>에 있는 링크를 참고하기 바랍니다.</p>
  495. </div>
  496. </div>
  497. </div>
  498. <script src="../resources/prettify.js"></script>
  499. <script src="../resources/lesson.js"></script>
  500. </body></html>