align-html-elements-to-3d.html 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730
  1. <!DOCTYPE html><html lang="en"><head>
  2. <meta charset="utf-8">
  3. <title>Aligning HTML Elements to 3D</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 – Aligning HTML Elements to 3D">
  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. <!-- Import maps polyfill -->
  14. <!-- Remove this when import maps will be widely supported -->
  15. <script async src="https://unpkg.com/[email protected]/dist/es-module-shims.js"></script>
  16. <script type="importmap">
  17. {
  18. "imports": {
  19. "three": "../../build/three.module.js"
  20. }
  21. }
  22. </script>
  23. </head>
  24. <body>
  25. <div class="container">
  26. <div class="lesson-title">
  27. <h1>Aligning HTML Elements to 3D</h1>
  28. </div>
  29. <div class="lesson">
  30. <div class="lesson-main">
  31. <p>This article is part of a series of articles about three.js. The first article
  32. is <a href="fundamentals.html">three.js fundamentals</a>. If you haven't read that
  33. yet and you're new to three.js you might want to consider starting there. </p>
  34. <p>Sometimes you'd like to display some text in your 3D scene. You have many options
  35. each with pluses and minuses.</p>
  36. <ul>
  37. <li><p>Use 3D text</p>
  38. <p>If you look at the <a href="primitives.html">primitives article</a> you'll see <a href="/docs/#api/en/geometries/TextGeometry"><code class="notranslate" translate="no">TextGeometry</code></a> which
  39. makes 3D text. This might be useful for flying logos but probably not so useful for stats, info,
  40. or labelling lots of things.</p>
  41. </li>
  42. <li><p>Use a texture with 2D text drawn into it.</p>
  43. <p>The article on <a href="canvas-textures.html">using a Canvas as a texture</a> shows using
  44. a canvas as a texture. You can draw text into a canvas and <a href="billboards.html">display it as a billboard</a>.
  45. The plus here might be that the text is integrated into the 3D scene. For something like a computer terminal
  46. shown in a 3D scene this might be perfect.</p>
  47. </li>
  48. <li><p>Use HTML Elements and position them to match the 3D</p>
  49. <p>The benefits to this approach is you can use all of HTML. Your HTML can have multiple elements. It can
  50. by styled with CSS. It can also be selected by the user as it is actual text. </p>
  51. </li>
  52. </ul>
  53. <p>This article will cover this last approach.</p>
  54. <p>Let's start simple. We'll make a 3D scene with a few primitives and then add a label to each primitive. We'll start
  55. with an example from <a href="responsive.html">the article on responsive pages</a> </p>
  56. <p>We'll add some <a href="/docs/#examples/controls/OrbitControls"><code class="notranslate" translate="no">OrbitControls</code></a> like we did in <a href="lights.html">the article on lighting</a>.</p>
  57. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">import * as THREE from 'three';
  58. +import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
  59. </pre>
  60. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const controls = new OrbitControls(camera, canvas);
  61. controls.target.set(0, 0, 0);
  62. controls.update();
  63. </pre>
  64. <p>We need to provide an HTML element to contain our label elements</p>
  65. <pre class="prettyprint showlinemods notranslate lang-html" translate="no">&lt;body&gt;
  66. - &lt;canvas id="c"&gt;&lt;/canvas&gt;
  67. + &lt;div id="container"&gt;
  68. + &lt;canvas id="c"&gt;&lt;/canvas&gt;
  69. + &lt;div id="labels"&gt;&lt;/div&gt;
  70. + &lt;/div&gt;
  71. &lt;/body&gt;
  72. </pre>
  73. <p>By putting both the canvas and the <code class="notranslate" translate="no">&lt;div id="labels"&gt;</code> inside a
  74. parent container we can make them overlap with this CSS</p>
  75. <pre class="prettyprint showlinemods notranslate lang-css" translate="no">#c {
  76. - width: 100%;
  77. - height: 100%;
  78. + width: 100%; /* let our container decide our size */
  79. + height: 100%;
  80. display: block;
  81. }
  82. +#container {
  83. + position: relative; /* makes this the origin of its children */
  84. + width: 100%;
  85. + height: 100%;
  86. + overflow: hidden;
  87. +}
  88. +#labels {
  89. + position: absolute; /* let us position ourself inside the container */
  90. + left: 0; /* make our position the top left of the container */
  91. + top: 0;
  92. + color: white;
  93. +}
  94. </pre>
  95. <p>let's also add some CSS for the labels themselves</p>
  96. <pre class="prettyprint showlinemods notranslate lang-css" translate="no">#labels&gt;div {
  97. position: absolute; /* let us position them inside the container */
  98. left: 0; /* make their default position the top left of the container */
  99. top: 0;
  100. cursor: pointer; /* change the cursor to a hand when over us */
  101. font-size: large;
  102. user-select: none; /* don't let the text get selected */
  103. text-shadow: /* create a black outline */
  104. -1px -1px 0 #000,
  105. 0 -1px 0 #000,
  106. 1px -1px 0 #000,
  107. 1px 0 0 #000,
  108. 1px 1px 0 #000,
  109. 0 1px 0 #000,
  110. -1px 1px 0 #000,
  111. -1px 0 0 #000;
  112. }
  113. #labels&gt;div:hover {
  114. color: red;
  115. }
  116. </pre>
  117. <p>Now into our code we don't have to add too much. We had a function
  118. <code class="notranslate" translate="no">makeInstance</code> that we used to generate cubes. Let's make it
  119. so it also adds a label element.</p>
  120. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">+const labelContainerElem = document.querySelector('#labels');
  121. -function makeInstance(geometry, color, x) {
  122. +function makeInstance(geometry, color, x, name) {
  123. const material = new THREE.MeshPhongMaterial({color});
  124. const cube = new THREE.Mesh(geometry, material);
  125. scene.add(cube);
  126. cube.position.x = x;
  127. + const elem = document.createElement('div');
  128. + elem.textContent = name;
  129. + labelContainerElem.appendChild(elem);
  130. - return cube;
  131. + return {cube, elem};
  132. }
  133. </pre>
  134. <p>As you can see we're adding a <code class="notranslate" translate="no">&lt;div&gt;</code> to the container, one for each cube. We're
  135. also returning an object with both the <code class="notranslate" translate="no">cube</code> and the <code class="notranslate" translate="no">elem</code> for the label.</p>
  136. <p>Calling it we need to provide a name for each</p>
  137. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const cubes = [
  138. - makeInstance(geometry, 0x44aa88, 0),
  139. - makeInstance(geometry, 0x8844aa, -2),
  140. - makeInstance(geometry, 0xaa8844, 2),
  141. + makeInstance(geometry, 0x44aa88, 0, 'Aqua'),
  142. + makeInstance(geometry, 0x8844aa, -2, 'Purple'),
  143. + makeInstance(geometry, 0xaa8844, 2, 'Gold'),
  144. ];
  145. </pre>
  146. <p>What remains is positioning the label elements at render time</p>
  147. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const tempV = new THREE.Vector3();
  148. ...
  149. -cubes.forEach((cube, ndx) =&gt; {
  150. +cubes.forEach((cubeInfo, ndx) =&gt; {
  151. + const {cube, elem} = cubeInfo;
  152. const speed = 1 + ndx * .1;
  153. const rot = time * speed;
  154. cube.rotation.x = rot;
  155. cube.rotation.y = rot;
  156. + // get the position of the center of the cube
  157. + cube.updateWorldMatrix(true, false);
  158. + cube.getWorldPosition(tempV);
  159. +
  160. + // get the normalized screen coordinate of that position
  161. + // x and y will be in the -1 to +1 range with x = -1 being
  162. + // on the left and y = -1 being on the bottom
  163. + tempV.project(camera);
  164. +
  165. + // convert the normalized position to CSS coordinates
  166. + const x = (tempV.x * .5 + .5) * canvas.clientWidth;
  167. + const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
  168. +
  169. + // move the elem to that position
  170. + elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
  171. });
  172. </pre>
  173. <p>And with that we have labels aligned to their corresponding objects.</p>
  174. <p></p><div translate="no" class="threejs_example_container notranslate">
  175. <div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/align-html-to-3d.html"></iframe></div>
  176. <a class="threejs_center" href="/manual/examples/align-html-to-3d.html" target="_blank">click here to open in a separate window</a>
  177. </div>
  178. <p></p>
  179. <p>There are a couple of issues we probably want to deal with.</p>
  180. <p>One is that if we rotate the objects so they overlap all the labels
  181. overlap as well.</p>
  182. <div class="threejs_center"><img src="../resources/images/overlapping-labels.png" style="width: 307px;"></div>
  183. <p>Another is that if we zoom way out so that the objects go outside
  184. the frustum the labels will still appear.</p>
  185. <p>A possible solution to the problem of overlapping objects is to use
  186. the <a href="picking.html">picking code from the article on picking</a>.
  187. We'll pass in the position of the object on the screen and then
  188. ask the <code class="notranslate" translate="no">RayCaster</code> to tell us which objects were intersected.
  189. If our object is not the first one then we are not in the front.</p>
  190. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const tempV = new THREE.Vector3();
  191. +const raycaster = new THREE.Raycaster();
  192. ...
  193. cubes.forEach((cubeInfo, ndx) =&gt; {
  194. const {cube, elem} = cubeInfo;
  195. const speed = 1 + ndx * .1;
  196. const rot = time * speed;
  197. cube.rotation.x = rot;
  198. cube.rotation.y = rot;
  199. // get the position of the center of the cube
  200. cube.updateWorldMatrix(true, false);
  201. cube.getWorldPosition(tempV);
  202. // get the normalized screen coordinate of that position
  203. // x and y will be in the -1 to +1 range with x = -1 being
  204. // on the left and y = -1 being on the bottom
  205. tempV.project(camera);
  206. + // ask the raycaster for all the objects that intersect
  207. + // from the eye toward this object's position
  208. + raycaster.setFromCamera(tempV, camera);
  209. + const intersectedObjects = raycaster.intersectObjects(scene.children);
  210. + // We're visible if the first intersection is this object.
  211. + const show = intersectedObjects.length &amp;&amp; cube === intersectedObjects[0].object;
  212. +
  213. + if (!show) {
  214. + // hide the label
  215. + elem.style.display = 'none';
  216. + } else {
  217. + // un-hide the label
  218. + elem.style.display = '';
  219. // convert the normalized position to CSS coordinates
  220. const x = (tempV.x * .5 + .5) * canvas.clientWidth;
  221. const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
  222. // move the elem to that position
  223. elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
  224. + }
  225. });
  226. </pre>
  227. <p>This handles overlapping.</p>
  228. <p>To handle going outside the frustum we can add this check if the origin of
  229. the object is outside the frustum by checking <code class="notranslate" translate="no">tempV.z</code></p>
  230. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">- if (!show) {
  231. + if (!show || Math.abs(tempV.z) &gt; 1) {
  232. // hide the label
  233. elem.style.display = 'none';
  234. </pre>
  235. <p>This <em>kind of</em> works because the normalized coordinates we computed include a <code class="notranslate" translate="no">z</code>
  236. value that goes from -1 when at the <code class="notranslate" translate="no">near</code> part of our camera frustum to +1 when
  237. at the <code class="notranslate" translate="no">far</code> part of our camera frustum.</p>
  238. <p></p><div translate="no" class="threejs_example_container notranslate">
  239. <div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/align-html-to-3d-w-hiding.html"></iframe></div>
  240. <a class="threejs_center" href="/manual/examples/align-html-to-3d-w-hiding.html" target="_blank">click here to open in a separate window</a>
  241. </div>
  242. <p></p>
  243. <p>For the frustum check, the solution above fails as we're only checking the origin of the object. For a large
  244. object. That origin might go outside the frustum but half of the object might still be in the frustum.</p>
  245. <p>A more correct solution would be to check if the object itself is in the frustum
  246. or not. Unfortunate that check is slow. For 3 cubes it will not be a problem
  247. but for many objects it might be.</p>
  248. <p>Three.js provides some functions to check if an object's bounding sphere is
  249. in a frustum</p>
  250. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">// at init time
  251. const frustum = new THREE.Frustum();
  252. const viewProjection = new THREE.Matrix4();
  253. ...
  254. // before checking
  255. camera.updateMatrix();
  256. camera.updateMatrixWorld();
  257. camera.matrixWorldInverse.copy(camera.matrixWorld).invert();
  258. ...
  259. // then for each mesh
  260. someMesh.updateMatrix();
  261. someMesh.updateMatrixWorld();
  262. viewProjection.multiplyMatrices(
  263. camera.projectionMatrix, camera.matrixWorldInverse);
  264. frustum.setFromProjectionMatrix(viewProjection);
  265. const inFrustum = frustum.contains(someMesh));
  266. </pre>
  267. <p>Our current overlapping solution has similar issues. Picking is slow. We could
  268. use gpu based picking like we covered in the <a href="picking.html">picking
  269. article</a> but that is also not free. Which solution you
  270. chose depends on your needs.</p>
  271. <p>Another issue is the order the labels appear. If we change the code to have
  272. longer labels</p>
  273. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const cubes = [
  274. - makeInstance(geometry, 0x44aa88, 0, 'Aqua'),
  275. - makeInstance(geometry, 0x8844aa, -2, 'Purple'),
  276. - makeInstance(geometry, 0xaa8844, 2, 'Gold'),
  277. + makeInstance(geometry, 0x44aa88, 0, 'Aqua Colored Box'),
  278. + makeInstance(geometry, 0x8844aa, -2, 'Purple Colored Box'),
  279. + makeInstance(geometry, 0xaa8844, 2, 'Gold Colored Box'),
  280. ];
  281. </pre>
  282. <p>and set the CSS so these don't wrap</p>
  283. <pre class="prettyprint showlinemods notranslate lang-css" translate="no">#labels&gt;div {
  284. + white-space: nowrap;
  285. </pre>
  286. <p>Then we can run into this issue</p>
  287. <div class="threejs_center"><img src="../resources/images/label-sorting-issue.png" style="width: 401px;"></div>
  288. <p>You can see above the purple box is in the back but its label is in front of the aqua box.</p>
  289. <p>We can fix this by setting the <code class="notranslate" translate="no">zIndex</code> of each element. The projected position has a <code class="notranslate" translate="no">z</code> value
  290. that goes from -1 in front to positive 1 in back. <code class="notranslate" translate="no">zIndex</code> is required to be an integer and goes the
  291. opposite direction meaning for <code class="notranslate" translate="no">zIndex</code> greater values are in front so the following code should work.</p>
  292. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">// convert the normalized position to CSS coordinates
  293. const x = (tempV.x * .5 + .5) * canvas.clientWidth;
  294. const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
  295. // move the elem to that position
  296. elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
  297. +// set the zIndex for sorting
  298. +elem.style.zIndex = (-tempV.z * .5 + .5) * 100000 | 0;
  299. </pre>
  300. <p>Because of the way the projected z value works we need to pick a large number to spread out the values
  301. otherwise many will have the same value. To make sure the labels don't overlap with other parts of
  302. the page we can tell the browser to create a new <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Positioning/Understanding_z_index/The_stacking_context">stacking context</a>
  303. by setting the <code class="notranslate" translate="no">z-index</code> of the container of the labels</p>
  304. <pre class="prettyprint showlinemods notranslate lang-css" translate="no">#labels {
  305. position: absolute; /* let us position ourself inside the container */
  306. + z-index: 0; /* make a new stacking context so children don't sort with rest of page */
  307. left: 0; /* make our position the top left of the container */
  308. top: 0;
  309. color: white;
  310. z-index: 0;
  311. }
  312. </pre>
  313. <p>and now the labels should always be in the correct order.</p>
  314. <p></p><div translate="no" class="threejs_example_container notranslate">
  315. <div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/align-html-to-3d-w-sorting.html"></iframe></div>
  316. <a class="threejs_center" href="/manual/examples/align-html-to-3d-w-sorting.html" target="_blank">click here to open in a separate window</a>
  317. </div>
  318. <p></p>
  319. <p>While we're at it let's do one more example to show one more issue.
  320. Let's draw a globe like Google Maps and label the countries.</p>
  321. <p>I found <a href="http://thematicmapping.org/downloads/world_borders.php">this data</a>
  322. which contains the borders of countries. It's licensed as
  323. <a href="http://creativecommons.org/licenses/by-sa/3.0/">CC-BY-SA</a>.</p>
  324. <p>I <a href="https://github.com/mrdoob/three.js/blob/master/manual/resources/tools/geo-picking/">wrote some code</a>
  325. to load the data, and generate country outlines and some JSON data with the names
  326. of the countries and their locations.</p>
  327. <div class="threejs_center"><img src="../examples/resources/data/world/country-outlines-4k.png" style="background: black; width: 700px"></div>
  328. <p>The JSON data is an array of entries something like this</p>
  329. <pre class="prettyprint showlinemods notranslate lang-json" translate="no">[
  330. {
  331. "name": "Algeria",
  332. "min": [
  333. -8.667223,
  334. 18.976387
  335. ],
  336. "max": [
  337. 11.986475,
  338. 37.091385
  339. ],
  340. "area": 238174,
  341. "lat": 28.163,
  342. "lon": 2.632,
  343. "population": {
  344. "2005": 32854159
  345. }
  346. },
  347. ...
  348. </pre>
  349. <p>where min, max, lat, lon, are all in latitude and longitude degrees.</p>
  350. <p>Let's load it up. The code is based on the examples from <a href="optimize-lots-of-objects.html">optimizing lots of
  351. objects</a> though we are not drawing lots
  352. of objects we'll be using the same solutions for <a href="rendering-on-demand.html">rendering on
  353. demand</a>.</p>
  354. <p>The first thing is to make a sphere and use the outline texture.</p>
  355. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">{
  356. const loader = new THREE.TextureLoader();
  357. const texture = loader.load('resources/data/world/country-outlines-4k.png', render);
  358. const geometry = new THREE.SphereGeometry(1, 64, 32);
  359. const material = new THREE.MeshBasicMaterial({map: texture});
  360. scene.add(new THREE.Mesh(geometry, material));
  361. }
  362. </pre>
  363. <p>Then let's load the JSON file by first making a loader</p>
  364. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">async function loadJSON(url) {
  365. const req = await fetch(url);
  366. return req.json();
  367. }
  368. </pre>
  369. <p>and then calling it</p>
  370. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">let countryInfos;
  371. async function loadCountryData() {
  372. countryInfos = await loadJSON('resources/data/world/country-info.json');
  373. ...
  374. }
  375. requestRenderIfNotRequested();
  376. }
  377. loadCountryData();
  378. </pre>
  379. <p>Now let's use that data to generate and place the labels.</p>
  380. <p>In the article on <a href="optimize-lots-of-objects.html">optimizing lots of objects</a>
  381. we had setup a small scene graph of helper objects to make it easy to
  382. compute latitude and longitude positions on our globe. See that article
  383. for an explanation of how they work.</p>
  384. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const lonFudge = Math.PI * 1.5;
  385. const latFudge = Math.PI;
  386. // these helpers will make it easy to position the boxes
  387. // We can rotate the lon helper on its Y axis to the longitude
  388. const lonHelper = new THREE.Object3D();
  389. // We rotate the latHelper on its X axis to the latitude
  390. const latHelper = new THREE.Object3D();
  391. lonHelper.add(latHelper);
  392. // The position helper moves the object to the edge of the sphere
  393. const positionHelper = new THREE.Object3D();
  394. positionHelper.position.z = 1;
  395. latHelper.add(positionHelper);
  396. </pre>
  397. <p>We'll use that to compute a position for each label</p>
  398. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const labelParentElem = document.querySelector('#labels');
  399. for (const countryInfo of countryInfos) {
  400. const {lat, lon, name} = countryInfo;
  401. // adjust the helpers to point to the latitude and longitude
  402. lonHelper.rotation.y = THREE.MathUtils.degToRad(lon) + lonFudge;
  403. latHelper.rotation.x = THREE.MathUtils.degToRad(lat) + latFudge;
  404. // get the position of the lat/lon
  405. positionHelper.updateWorldMatrix(true, false);
  406. const position = new THREE.Vector3();
  407. positionHelper.getWorldPosition(position);
  408. countryInfo.position = position;
  409. // add an element for each country
  410. const elem = document.createElement('div');
  411. elem.textContent = name;
  412. labelParentElem.appendChild(elem);
  413. countryInfo.elem = elem;
  414. </pre>
  415. <p>The code above looks very similar to the code we wrote for making cube labels
  416. making an element per label. When we're done we have an array, <code class="notranslate" translate="no">countryInfos</code>,
  417. with one entry for each country to which we've added an <code class="notranslate" translate="no">elem</code> property for
  418. the label element for that country and a <code class="notranslate" translate="no">position</code> with its position on the
  419. globe.</p>
  420. <p>Just like we did for the cubes we need to update the position of the
  421. labels and render time.</p>
  422. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const tempV = new THREE.Vector3();
  423. function updateLabels() {
  424. // exit if we have not yet loaded the JSON file
  425. if (!countryInfos) {
  426. return;
  427. }
  428. for (const countryInfo of countryInfos) {
  429. const {position, elem} = countryInfo;
  430. // get the normalized screen coordinate of that position
  431. // x and y will be in the -1 to +1 range with x = -1 being
  432. // on the left and y = -1 being on the bottom
  433. tempV.copy(position);
  434. tempV.project(camera);
  435. // convert the normalized position to CSS coordinates
  436. const x = (tempV.x * .5 + .5) * canvas.clientWidth;
  437. const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
  438. // move the elem to that position
  439. elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
  440. // set the zIndex for sorting
  441. elem.style.zIndex = (-tempV.z * .5 + .5) * 100000 | 0;
  442. }
  443. }
  444. </pre>
  445. <p>You can see the code above is substantially similar to the cube example before.
  446. The only major difference is we pre-computed the label positions at init time.
  447. We can do this because the globe never moves. Only our camera moves.</p>
  448. <p>Lastly we need to call <code class="notranslate" translate="no">updateLabels</code> in our render loop</p>
  449. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">function render() {
  450. renderRequested = false;
  451. if (resizeRendererToDisplaySize(renderer)) {
  452. const canvas = renderer.domElement;
  453. camera.aspect = canvas.clientWidth / canvas.clientHeight;
  454. camera.updateProjectionMatrix();
  455. }
  456. controls.update();
  457. + updateLabels();
  458. renderer.render(scene, camera);
  459. }
  460. </pre>
  461. <p>And this is what we get</p>
  462. <p></p><div translate="no" class="threejs_example_container notranslate">
  463. <div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/align-html-elements-to-3d-globe-too-many-labels.html"></iframe></div>
  464. <a class="threejs_center" href="/manual/examples/align-html-elements-to-3d-globe-too-many-labels.html" target="_blank">click here to open in a separate window</a>
  465. </div>
  466. <p></p>
  467. <p>That is way too many labels!</p>
  468. <p>We have 2 problems.</p>
  469. <ol>
  470. <li><p>Labels facing away from us are showing up.</p>
  471. </li>
  472. <li><p>There are too many labels.</p>
  473. </li>
  474. </ol>
  475. <p>For issue #1 we can't really use the <code class="notranslate" translate="no">RayCaster</code> like we did above as there is
  476. nothing to intersect except the sphere. Instead what we can do is check if that
  477. particular country is facing away from us or not. This works because the label
  478. positions are around a sphere. In fact we're using a unit sphere, a sphere with
  479. a radius of 1.0. That means the positions are already unit directions making
  480. the math relatively easy.</p>
  481. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const tempV = new THREE.Vector3();
  482. +const cameraToPoint = new THREE.Vector3();
  483. +const cameraPosition = new THREE.Vector3();
  484. +const normalMatrix = new THREE.Matrix3();
  485. function updateLabels() {
  486. // exit if we have not yet loaded the JSON file
  487. if (!countryInfos) {
  488. return;
  489. }
  490. + const minVisibleDot = 0.2;
  491. + // get a matrix that represents a relative orientation of the camera
  492. + normalMatrix.getNormalMatrix(camera.matrixWorldInverse);
  493. + // get the camera's position
  494. + camera.getWorldPosition(cameraPosition);
  495. for (const countryInfo of countryInfos) {
  496. const {position, elem} = countryInfo;
  497. + // Orient the position based on the camera's orientation.
  498. + // Since the sphere is at the origin and the sphere is a unit sphere
  499. + // this gives us a camera relative direction vector for the position.
  500. + tempV.copy(position);
  501. + tempV.applyMatrix3(normalMatrix);
  502. +
  503. + // compute the direction to this position from the camera
  504. + cameraToPoint.copy(position);
  505. + cameraToPoint.applyMatrix4(camera.matrixWorldInverse).normalize();
  506. +
  507. + // get the dot product of camera relative direction to this position
  508. + // on the globe with the direction from the camera to that point.
  509. + // 1 = facing directly towards the camera
  510. + // 0 = exactly on tangent of the sphere from the camera
  511. + // &lt; 0 = facing away
  512. + const dot = tempV.dot(cameraToPoint);
  513. +
  514. + // if the orientation is not facing us hide it.
  515. + if (dot &lt; minVisibleDot) {
  516. + elem.style.display = 'none';
  517. + continue;
  518. + }
  519. +
  520. + // restore the element to its default display style
  521. + elem.style.display = '';
  522. // get the normalized screen coordinate of that position
  523. // x and y will be in the -1 to +1 range with x = -1 being
  524. // on the left and y = -1 being on the bottom
  525. tempV.copy(position);
  526. tempV.project(camera);
  527. // convert the normalized position to CSS coordinates
  528. const x = (tempV.x * .5 + .5) * canvas.clientWidth;
  529. const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
  530. // move the elem to that position
  531. countryInfo.elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
  532. // set the zIndex for sorting
  533. elem.style.zIndex = (-tempV.z * .5 + .5) * 100000 | 0;
  534. }
  535. }
  536. </pre>
  537. <p>Above we use the positions as a direction and get that direction relative to the
  538. camera. Then we get the camera relative direction from the camera to that
  539. position on the globe and take the <em>dot product</em>. The dot product returns the cosine
  540. of the angle between the to vectors. This gives us a value from -1
  541. to +1 where -1 means the label is facing the camera, 0 means the label is directly
  542. on the edge of the sphere relative to the camera, and anything greater than zero is
  543. behind. We then use that value to show or hide the element.</p>
  544. <div class="spread">
  545. <div>
  546. <div data-diagram="dotProduct" style="height: 400px"></div>
  547. </div>
  548. </div>
  549. <p>In the diagram above we can see the dot product of the direction the label is
  550. facing to direction from the camera to that position. If you rotate the
  551. direction you'll see the dot product is -1.0 when the direction is directly
  552. facing the camera, it's 0.0 when exactly on the tangent of the sphere relative
  553. to the camera or to put it another way it's 0 when the 2 vectors are
  554. perpendicular to each other, 90 degrees It's greater than zero with the label is
  555. behind the sphere.</p>
  556. <p>For issue #2, too many labels we need some way to decide which labels
  557. to show. One way would be to only show labels for large countries.
  558. The data we're loading contains min and max values for the area a
  559. country covers. From that we can compute an area and then use that
  560. area to decide whether or not to display the country.</p>
  561. <p>At init time let's compute the area</p>
  562. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const labelParentElem = document.querySelector('#labels');
  563. for (const countryInfo of countryInfos) {
  564. const {lat, lon, min, max, name} = countryInfo;
  565. // adjust the helpers to point to the latitude and longitude
  566. lonHelper.rotation.y = THREE.MathUtils.degToRad(lon) + lonFudge;
  567. latHelper.rotation.x = THREE.MathUtils.degToRad(lat) + latFudge;
  568. // get the position of the lat/lon
  569. positionHelper.updateWorldMatrix(true, false);
  570. const position = new THREE.Vector3();
  571. positionHelper.getWorldPosition(position);
  572. countryInfo.position = position;
  573. + // compute the area for each country
  574. + const width = max[0] - min[0];
  575. + const height = max[1] - min[1];
  576. + const area = width * height;
  577. + countryInfo.area = area;
  578. // add an element for each country
  579. const elem = document.createElement('div');
  580. elem.textContent = name;
  581. labelParentElem.appendChild(elem);
  582. countryInfo.elem = elem;
  583. }
  584. </pre>
  585. <p>Then at render time let's use the area to decide to display the label
  586. or not</p>
  587. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">+const large = 20 * 20;
  588. const maxVisibleDot = 0.2;
  589. // get a matrix that represents a relative orientation of the camera
  590. normalMatrix.getNormalMatrix(camera.matrixWorldInverse);
  591. // get the camera's position
  592. camera.getWorldPosition(cameraPosition);
  593. for (const countryInfo of countryInfos) {
  594. - const {position, elem} = countryInfo;
  595. + const {position, elem, area} = countryInfo;
  596. + // large enough?
  597. + if (area &lt; large) {
  598. + elem.style.display = 'none';
  599. + continue;
  600. + }
  601. ...
  602. </pre>
  603. <p>Finally, since I'm not sure what good values are for these settings lets
  604. add a GUI so we can play with them</p>
  605. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">import * as THREE from 'three';
  606. import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
  607. +import {GUI} from 'three/addons/libs/lil-gui.module.min.js';
  608. </pre>
  609. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">+const settings = {
  610. + minArea: 20,
  611. + maxVisibleDot: -0.2,
  612. +};
  613. +const gui = new GUI({width: 300});
  614. +gui.add(settings, 'minArea', 0, 50).onChange(requestRenderIfNotRequested);
  615. +gui.add(settings, 'maxVisibleDot', -1, 1, 0.01).onChange(requestRenderIfNotRequested);
  616. function updateLabels() {
  617. if (!countryInfos) {
  618. return;
  619. }
  620. - const large = 20 * 20;
  621. - const maxVisibleDot = -0.2;
  622. + const large = settings.minArea * settings.minArea;
  623. // get a matrix that represents a relative orientation of the camera
  624. normalMatrix.getNormalMatrix(camera.matrixWorldInverse);
  625. // get the camera's position
  626. camera.getWorldPosition(cameraPosition);
  627. for (const countryInfo of countryInfos) {
  628. ...
  629. // if the orientation is not facing us hide it.
  630. - if (dot &gt; maxVisibleDot) {
  631. + if (dot &gt; settings.maxVisibleDot) {
  632. elem.style.display = 'none';
  633. continue;
  634. }
  635. </pre>
  636. <p>and here's the result</p>
  637. <p></p><div translate="no" class="threejs_example_container notranslate">
  638. <div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/align-html-elements-to-3d-globe.html"></iframe></div>
  639. <a class="threejs_center" href="/manual/examples/align-html-elements-to-3d-globe.html" target="_blank">click here to open in a separate window</a>
  640. </div>
  641. <p></p>
  642. <p>You can see as you rotate the earth labels that go behind disappear.
  643. Adjust the <code class="notranslate" translate="no">minVisibleDot</code> to see the cutoff change.
  644. You can also adjust the <code class="notranslate" translate="no">minArea</code> value to see larger or smaller countries
  645. appear.</p>
  646. <p>The more I worked on this the more I realized just how much
  647. work is put into Google Maps. They have also have to decide which labels to
  648. show. I'm pretty sure they use all kinds of criteria. For example your current
  649. location, your default language setting, your account settings if you have an
  650. account, they probably use population or popularity, they might give priority
  651. to the countries in the center of the view, etc ... Lots to think about.</p>
  652. <p>In any case I hope these examples gave you some idea of how to align HTML
  653. elements with your 3D. A few things I might change.</p>
  654. <p>Next up let's make it so you can <a href="indexed-textures.html">pick and highlight a country</a>.</p>
  655. <p><link rel="stylesheet" href="../resources/threejs-align-html-elements-to-3d.css"></p>
  656. <script type="module" src="../resources/threejs-align-html-elements-to-3d.js"></script>
  657. </div>
  658. </div>
  659. </div>
  660. <script src="../resources/prettify.js"></script>
  661. <script src="../resources/lesson.js"></script>
  662. </body></html>