Browse Source

add indexed textures article

Gregg Tavares 6 years ago
parent
commit
c273b0070a

BIN
threejs/lessons/resources/images/7x7-indexed-face.png


+ 659 - 0
threejs/lessons/threejs-indexed-textures.md

@@ -0,0 +1,659 @@
+Title: Three.js Indexed Textures for Picking and Color
+Description: Using Indexed Textures for Picking and Color
+
+This article is a continuation of [an article about aligning html elements to 3d](threejs-align-html-elements-to-3d.html).
+If you haven't read that yet you should start there before continuing here.
+
+Sometimes using three.js requires coming up with creative solutions.
+I'm not sure this is a great solution but I thought I'd share it and
+you can see if it suggests any solutions for your needs.
+
+In the [previous article](threejs-align-html-elements-to-3d.html) we
+displayed country names around a 3d globe. How would we go about letting
+the user select a country and show their selection?
+
+The first idea that comes to mind is to generate geometry for each country.
+We could [use a picking solution](threejs-picking.html) like we covered before.
+We'd build 3D geometry for each country. If the user clicks on the mesh for 
+that country we'd know what country was clicked.
+
+So, just to check that solution I tried generating 3D meshes of all the countries
+using the same data I used to generate the outlines 
+[in the previous article](threejs-align-html-elements-to-3d.html).
+The result was a 15.5meg binary GLTF (.glb) file. Making the user download 15.5meg
+sounds like too much to me.
+
+There are lots of ways to compress the data. The first would probably be
+to apply some algorithm to lower the resolution of the outlines. I didn't spend
+any time pursuing that solution. For borders of the USA that's probably a huge
+win. For a borders of Canada probably much less. 
+
+Another solution would be to use just actual data compression. For example gzipping
+the file brought it down to 11meg. That's 30% less but arguably not enough.
+
+We could store all the data as 16bit ranged values instead of 32bit float values. 
+Or we could use something like [draco compression](https://google.github.io/draco/)
+and maybe that would be enough. I didn't check and I would encourage you to check 
+yourself and tell me how it goes as I'd love to know. 😅
+
+In my case I thought about [the GPU picking solution](threejs-picking.html) 
+we covered at the end of [the article on picking](threejs-picking.html). In
+that solution we drew every mesh with a unique color that represented that
+mesh's id. We then drew all the meshes and looked at the color that was clicked
+on.
+
+Taking inspiration from that we could pre-generate a map of countries where
+each country's color is its index number in our array of countries. We could
+then use a similar GPU picking technique. We'd draw the globe off screen using
+this index texture. Looking at the color of the pixel the user clicks would 
+tell us the country id.
+
+So, I [wrote some code](https://github.com/greggman/threejsfundamentals/blob/master/threejs/lessons/tools/geo-picking/) 
+to generate such a texture. Here it is. 
+
+<div class="threejs_center"><img src="../resources/data/world/country-index-texture.png" style="width: 700px;"></div>
+
+It's only 217k, much better than the 14meg for the country meshes. In fact we could probably
+even lower the resolution but 217k seems good enough for now.
+
+So let's try using it for picking countries.
+
+Grabbing code from the [gpu picking example](threejs-picking.html) we need
+a scene for picking.
+
+```js
+const pickingScene = new THREE.Scene();
+pickingScene.background = new THREE.Color(0);
+```
+
+and we need to add the globe with the our index texture to the
+picking scene.
+
+```js
+{
+  const loader = new THREE.TextureLoader();
+  const geometry = new THREE.SphereBufferGeometry(1, 64, 32);
+
++  const indexTexture = loader.load('resources/data/world/country-index-texture.png', render);
++  indexTexture.minFilter = THREE.NearestFilter;
++  indexTexture.magFilter = THREE.NearestFilter;
++
++  const pickingMaterial = new THREE.MeshBasicMaterial({map: indexTexture});
++  pickingScene.add(new THREE.Mesh(geometry, pickingMaterial));
+
+  const texture = loader.load('resources/data/world/country-outlines-4k.png', render);
+  const material = new THREE.MeshBasicMaterial({map: texture});
+  scene.add(new THREE.Mesh(geometry, material));
+}
+```
+
+Then let's copy over the `GPUPickingHelper` class we used
+before with a few minor changes.
+
+```js
+class GPUPickHelper {
+  constructor() {
+    // create a 1x1 pixel render target
+    this.pickingTexture = new THREE.WebGLRenderTarget(1, 1);
+    this.pixelBuffer = new Uint8Array(4);
+-    this.pickedObject = null;
+-    this.pickedObjectSavedColor = 0;
+  }
+  pick(cssPosition, scene, camera) {
+    const {pickingTexture, pixelBuffer} = this;
+
+    // set the view offset to represent just a single pixel under the mouse
+    const pixelRatio = renderer.getPixelRatio();
+    camera.setViewOffset(
+        renderer.context.drawingBufferWidth,   // full width
+        renderer.context.drawingBufferHeight,  // full top
+        cssPosition.x * pixelRatio | 0,        // rect x
+        cssPosition.y * pixelRatio | 0,        // rect y
+        1,                                     // rect width
+        1,                                     // rect height
+    );
+    // render the scene
+    renderer.setRenderTarget(pickingTexture);
+    renderer.render(scene, camera);
+    renderer.setRenderTarget(null);
+    // clear the view offset so rendering returns to normal
+    camera.clearViewOffset();
+    //read the pixel
+    renderer.readRenderTargetPixels(
+        pickingTexture,
+        0,   // x
+        0,   // y
+        1,   // width
+        1,   // height
+        pixelBuffer);
+
++    const id =
++        (pixelBuffer[0] << 16) |
++        (pixelBuffer[1] <<  8) |
++        (pixelBuffer[2] <<  0);
++
++    return id;
+-    const id =
+-        (pixelBuffer[0] << 16) |
+-        (pixelBuffer[1] <<  8) |
+-        (pixelBuffer[2]      );
+-    const intersectedObject = idToObject[id];
+-    if (intersectedObject) {
+-      // pick the first object. It's the closest one
+-      this.pickedObject = intersectedObject;
+-      // save its color
+-      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
+-      // set its emissive color to flashing red/yellow
+-      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
+-    }
+  }
+}
+```
+
+Now we can use that to pick countries.
+
+```js
+const pickHelper = new GPUPickHelper();
+
+function pickCountry(event) {
+  // exit if we have not loaded the data yet
+  if (!countryInfos) {
+    return;
+  }
+
+  const position = {x: event.clientX, y: event.clientY};
+  const id = pickHelper.pick(position, pickingScene, camera);
+  if (id > 0) {
+    // we clicked a country. Toggle its 'selected' property
+    const countryInfo = countryInfos[id - 1];
+    const selected = !countryInfo.selected;
+    // if we're selecting this country and modifiers are not
+    // pressed unselect everything else.
+    if (selected && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
+      unselectAllCountries();
+    }
+    numCountriesSelected += selected ? 1 : -1;
+    countryInfo.selected = selected;
+  } else if (numCountriesSelected) {
+    // the ocean or sky was clicked
+    unselectAllCountries();
+  }
+  requestRenderIfNotRequested();
+}
+
+function unselectAllCountries() {
+  numCountriesSelected = 0;
+  countryInfos.forEach((countryInfo) => {
+    countryInfo.selected = false;
+  });
+}
+
+canvas.addEventListener('mouseup', pickCountry);
+
+let lastTouch;
+canvas.addEventListener('touchstart', (event) => {
+  // prevent the window from scrolling
+  event.preventDefault();
+  lastTouch = event.touches[0];
+}, {passive: false});
+canvas.addEventListener('touchsmove', (event) => {
+  lastTouch = event.touches[0];
+});
+canvas.addEventListener('touchend', () => {
+  pickCountry(lastTouch);
+});
+```
+
+The code above sets/unsets the `selected` property on
+the array of countries. If `shift` or `ctrl` or `cmd`
+is pressed then you can select more than one country.
+
+All that's left is showing the selected countries. For now
+let's just update the labels.
+
+```js
+function updateLabels() {
+  // exit if we have not loaded the data yet
+  if (!countryInfos) {
+    return;
+  }
+
+  const large = settings.minArea * settings.minArea;
+  const visibleDot = Math.cos(THREE.Math.degToRad(settings.visibleAngleDeg));
+  // get a matrix that represents a relative orientation of the camera
+  normalMatrix.getNormalMatrix(camera.matrixWorldInverse);
+  for (const countryInfo of countryInfos) {
+-    const {position, elem, area} = countryInfo;
+-    // large enough?
+-    if (area < large) {
++    const {position, elem, area, selected} = countryInfo;
++    const largeEnough = area >= large;
++    const show = selected || (numCountriesSelected === 0 && largeEnough);
++    if (!show) {
+      elem.style.display = 'none';
+      continue;
+    }
+
+    ...
+```
+
+and with that we should be able to pick countries
+
+{{{example url="../threejs-indexed-textures-picking.html" }}}
+
+The code stills shows countries based on their area but if you
+click one just that one will have a label.
+
+So that seems like a reasonable solution for picking countries 
+but what about highlighting the selected countries?
+
+For that we can take inspiration from *paletted graphics*.
+
+[Paletted graphics](https://en.wikipedia.org/wiki/Palette_(computing))
+or [Indexed Color](https://en.wikipedia.org/wiki/Indexed_color) is 
+what older systems like the Atari 800, Amiga, NES, 
+Super Nintendo, and even older IBM PCs used. Instead of storing bitmaps
+as RGB colors 8bits per color, 24 bytes per pixel or more, they stored 
+bitmaps as 8bit values or less. The value for each pixel was an index
+into a palette. So for example a value
+of 3 in the image means "display color 3". What color color#3 is is
+defined somewhere else called a "palette".
+
+In JavaScript you can think of it like this
+
+```js
+const face7x7PixelImageData = [
+  0, 1, 1, 1, 1, 1, 0,
+  1, 0, 0, 0, 0, 0, 1, 
+  1, 0, 2, 0, 2, 0, 1,
+  1, 0, 0, 0, 0, 0, 1,
+  1, 0, 3, 3, 3, 0, 1,
+  1, 0, 0, 0, 0, 0, 1,
+  0, 1, 1, 1, 1, 1, 1,
+];
+
+const palette = [
+  [255, 255, 255],  // white
+  [  0,   0,   0],  // black
+  [  0, 255, 255],  // cyan
+  [255,   0,   0],  // red
+];
+```
+
+Where each pixel in the image data is an index into palette. If you interpreted
+the image data through the palette above you'd get this image
+
+<div class="threejs_center"><img src="resources/images/7x7-indexed-face.png"></div>
+
+In our case we already have a texture above that has a different id
+per country. So, we could use that same texture through a palette
+texture to give each country its own color. By changing the palette
+texture we can color each individual country. For example by setting
+the entire palette texture to black and then for one country's entry
+in the palette a different color, we can highlight just that country.
+
+To do paletted index graphics requires some custom shader code. 
+Let's modify the default shaders in three.js. 
+That way we can use lighting and other features if we want.
+
+Like we covered in [the article on animating lots of objects](threejs-lots-of-objects-animated.html)
+we can modify the default shaders by adding a function to a material's
+`onBeforeCompile` property.
+
+The default fragment shader looks something like this before compiling.
+
+```glsl
+#include <common>
+#include <color_pars_fragment>
+#include <uv_pars_fragment>
+#include <uv2_pars_fragment>
+#include <map_pars_fragment>
+#include <alphamap_pars_fragment>
+#include <aomap_pars_fragment>
+#include <lightmap_pars_fragment>
+#include <envmap_pars_fragment>
+#include <fog_pars_fragment>
+#include <specularmap_pars_fragment>
+#include <logdepthbuf_pars_fragment>
+#include <clipping_planes_pars_fragment>
+void main() {
+	#include <clipping_planes_fragment>
+	vec4 diffuseColor = vec4( diffuse, opacity );
+	#include <logdepthbuf_fragment>
+	#include <map_fragment>
+	#include <color_fragment>
+	#include <alphamap_fragment>
+	#include <alphatest_fragment>
+	#include <specularmap_fragment>
+	ReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );
+	#ifdef USE_LIGHTMAP
+		reflectedLight.indirectDiffuse += texture2D( lightMap, vUv2 ).xyz * lightMapIntensity;
+	#else
+		reflectedLight.indirectDiffuse += vec3( 1.0 );
+	#endif
+	#include <aomap_fragment>
+	reflectedLight.indirectDiffuse *= diffuseColor.rgb;
+	vec3 outgoingLight = reflectedLight.indirectDiffuse;
+	#include <envmap_fragment>
+	gl_FragColor = vec4( outgoingLight, diffuseColor.a );
+	#include <premultiplied_alpha_fragment>
+	#include <tonemapping_fragment>
+	#include <encodings_fragment>
+	#include <fog_fragment>
+}
+```
+
+[Digging through all those snippets](https://github.com/mrdoob/three.js/tree/dev/src/renderers/shaders/ShaderChunk)
+we find that three.js uses a variable called `diffuseColor` to manage the 
+base material color. It sets this in the `<color_fragment>` [snippet](https://github.com/mrdoob/three.js/blob/dev/src/renderers/shaders/ShaderChunk/color_fragment.glsl.js)
+so we should be able to modify it after that point.
+
+`diffuseColor` at that point in the shader should already be the color from 
+our outline texture so we can look up the color from a palette texture 
+and mix them for the final result.
+
+Like we [did before](threejs-lots-of-objects-animated.html) we'll make an array
+of search and replacement strings and apply them to the shader in 
+`Material.onBeforeCompile`.
+
+```js
+{
+  const loader = new THREE.TextureLoader();
+  const geometry = new THREE.SphereBufferGeometry(1, 64, 32);
+
+  const indexTexture = loader.load('resources/data/world/country-index-texture.png', render);
+  indexTexture.minFilter = THREE.NearestFilter;
+  indexTexture.magFilter = THREE.NearestFilter;
+
+  const pickingMaterial = new THREE.MeshBasicMaterial({map: indexTexture});
+  pickingScene.add(new THREE.Mesh(geometry, pickingMaterial));
+
++  const fragmentShaderReplacements = [
++    {
++      from: '#include <common>',
++      to: `
++        #include <common>
++        uniform sampler2D indexTexture;
++        uniform sampler2D paletteTexture;
++        uniform float paletteTextureWidth;
++      `,
++    },
++    {
++      from: '#include <color_fragment>',
++      to: `
++        #include <color_fragment>
++        {
++          vec4 indexColor = texture2D(indexTexture, vUv);
++          float index = indexColor.r * 255.0 + indexColor.g * 255.0 * 256.0;
++          vec2 paletteUV = vec2((index + 0.5) / paletteTextureWidth, 0.5);
++          vec4 paletteColor = texture2D(paletteTexture, paletteUV);
++          // diffuseColor.rgb += paletteColor.rgb;   // white outlines
++          diffuseColor.rgb = paletteColor.rgb - diffuseColor.rgb;  // black outlines
++        }
++      `,
++    },
++  ];
+
+  const texture = loader.load('resources/data/world/country-outlines-4k.png', render);
+  const material = new THREE.MeshBasicMaterial({map: texture});
++  material.onBeforeCompile = function(shader) {
++    fragmentShaderReplacements.forEach((rep) => {
++      shader.fragmentShader = shader.fragmentShader.replace(rep.from, rep.to);
++    });
++  };
+  scene.add(new THREE.Mesh(geometry, material));
+}
+```
+
+Above can see above we add 3 uniforms, `indexTexture`, `paletteTexture`,
+and `paletteTextureWidth`. We get a color from the `indexTexture`
+and convert it to an index. `vUv` is the texture coordinates provided by 
+three.js. We then use that index to get a color out of the palette texture.
+We then mix the result with the current `diffuseColor`. The `diffuseColor`
+at this point is our black and white outline texture so if we add the 2 colors
+we'll get white outlines. If we subtract the current diffuse color we'll get
+black outlines.
+
+Before we can render we need to setup the palette texture
+and these 3 uniforms.
+
+For the palette texture it just needs to be wide enough to
+hold one color per country + one for the ocean (id = 0).
+There are 240 something countries. We could wait until the
+list of countries loads to get an exact number or look it up.
+There's not much harm in just picking some larger number so
+let's choose 512.
+
+Here's the code to create the palette texture
+
+```js
+const maxNumCountries = 512;
+const paletteTextureWidth = maxNumCountries;
+const paletteTextureHeight = 1;
+const palette = new Uint8Array(paletteTextureWidth * 3);
+const paletteTexture = new THREE.DataTexture(
+    palette, paletteTextureWidth, paletteTextureHeight, THREE.RGBFormat);
+paletteTexture.minFilter = THREE.NearestFilter;
+paletteTexture.magFilter = THREE.NearestFilter;
+```
+
+A `DataTexture` let's us give a texture raw data. In this case
+we're giving it 512 RGB colors, 3 bytes each where each byte is
+red, green, and blue respectively using values that go from 0 to 255.
+
+Let's fill it with random colors just to see it work
+
+```js
+for (let i = 1; i < palette.length; ++i) {
+  palette[i] = Math.random() * 256;
+}
+// set the ocean color (index #0)
+palette.set([100, 200, 255], 0);
+paletteTexture.needsUpdate = true;
+```
+
+Anytime we want three.js to update the palette texture with
+the contents of the `palette` array we need to set `paletteTexture.needsUpdate`
+to `true`.
+
+And then we still need to set the uniforms on the material.
+
+```js
+const geometry = new THREE.SphereBufferGeometry(1, 64, 32);
+const material = new THREE.MeshBasicMaterial({map: texture});
+material.onBeforeCompile = function(shader) {
+  fragmentShaderReplacements.forEach((rep) => {
+    shader.fragmentShader = shader.fragmentShader.replace(rep.from, rep.to);
+  });
++  shader.uniforms.paletteTexture = {value: paletteTexture};
++  shader.uniforms.indexTexture = {value: indexTexture};
++  shader.uniforms.paletteTextureWidth = {value: paletteTextureWidth};
+};
+scene.add(new THREE.Mesh(geometry, material));
+```
+
+and with that we get randomly colored countries.
+
+{{{example url="../threejs-indexed-textures-random-colors.html" }}}
+
+Now that we can see the index and palette textures are working
+let's manipulate the palette for highlighting.
+
+First let's make function that will let us pass in a three.js
+style color and give us values we can put in the palette texture.
+
+```js
+const tempColor = new THREE.Color();
+function get255BasedColor(color) {
+  tempColor.set(color);
+  return tempColor.toArray().map(v => v * 255);
+}
+```
+
+Calling it like this `color = get255BasedColor('red')` will
+return an array like `[255, 0, 0]`.
+
+Next let's use it to make a few colors and fill out the
+palette.
+
+```js
+const selectedColor = get255BasedColor('red');
+const unselectedColor = get255BasedColor('#444');
+const oceanColor = get255BasedColor('rgb(100,200,255)');
+resetPalette();
+
+function setPaletteColor(index, color) {
+  palette.set(color, index * 3);
+}
+
+function resetPalette() {
+  // make all colors the unselected color
+  for (let i = 1; i < maxNumCountries; ++i) {
+    setPaletteColor(i, unselectedColor);
+  }
+
+  // set the ocean color (index #0)
+  setPaletteColor(0, oceanColor);
+  paletteTexture.needsUpdate = true;
+}
+```
+
+Now let's use those functions to update the palette when a country
+is selected
+
+```js
+function pickCountry(event) {
+  // exit if we have not loaded the data yet
+  if (!countryInfos) {
+    return;
+  }
+
+  const position = {x: event.clientX, y: event.clientY};
+  const id = pickHelper.pick(position, pickingScene, camera);
+  if (id > 0) {
+    const countryInfo = countryInfos[id - 1];
+    const selected = !countryInfo.selected;
+    if (selected && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
+      unselectAllCountries();
+    }
+    numCountriesSelected += selected ? 1 : -1;
+    countryInfo.selected = selected;
++    setPaletteColor(id, selected ? selectedColor : unselectedColor);
++    paletteTexture.needsUpdate = true;
+  } else if (numCountriesSelected) {
+    unselectAllCountries();
+  }
+  requestRenderIfNotRequested();
+}
+
+function unselectAllCountries() {
+  numCountriesSelected = 0;
+  countryInfos.forEach((countryInfo) => {
+    countryInfo.selected = false;
+  });
++  resetPalette();
+}
+```
+
+and we that we should be able to highlight 1 or more countries.
+
+{{{example url="../threejs-indexed-textures-picking-and-highlighting.html" }}}
+
+That seems to work!
+
+One minor thing is we can't spin the globe without changing
+the selection state. If we select a country and then want to
+rotate the globe the selection will change.
+
+Let's try to fix that. Off the top of my head we can check 2 things.
+How much time passed between clicking and letting go.
+Another is did the user actually move the mouse. If the
+time is short or if they didn't move the mouse then it
+was probably a click. Otherwise they were probably trying
+to drag the globe.
+
+```js
++const maxClickTimeMs = 200;
++const maxMoveDeltaSq = 5 * 5;
++const startPosition = {};
++let startTimeMs;
++function recordStartTimeAndPosition(event) {
++  startTimeMs = performance.now();
++  startPosition.x = event.clientX;
++  startPosition.y = event.clientY;
++}
+
+function pickCountry(event) {
+  // exit if we have not loaded the data yet
+  if (!countryInfos) {
+    return;
+  }
+
++  // if it's been a moment since the user started
++  // then assume it was a drag action, not a select action
++  const clickTimeMs = performance.now() - startTimeMs;
++  if (clickTimeMs > maxClickTimeMs) {
++    return;
++  }
++
++  // if they moved assume it was a drag action
++  const moveDeltaSq = (startPosition.x - event.clientX) ** 2 +
++                      (startPosition.y - event.clientY) ** 2;
++  if (moveDeltaSq > maxMoveDeltaSq) {
++    return;
++  }
+
+
+  const position = {x: event.clientX, y: event.clientY};
+  const id = pickHelper.pick(position, pickingScene, camera);
+  if (id > 0) {
+    const countryInfo = countryInfos[id - 1];
+    const selected = !countryInfo.selected;
+    if (selected && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
+      unselectAllCountries();
+    }
+    numCountriesSelected += selected ? 1 : -1;
+    countryInfo.selected = selected;
+    setPaletteColor(id, selected ? selectedColor : unselectedColor);
+    paletteTexture.needsUpdate = true;
+  } else if (numCountriesSelected) {
+    unselectAllCountries();
+  }
+  requestRenderIfNotRequested();
+}
+
+function unselectAllCountries() {
+  numCountriesSelected = 0;
+  countryInfos.forEach((countryInfo) => {
+    countryInfo.selected = false;
+  });
+  resetPalette();
+}
+
++canvas.addEventListener('mousedown', recordStartTimeAndPosition);
+canvas.addEventListener('mouseup', pickCountry);
+
+let lastTouch;
+canvas.addEventListener('touchstart', (event) => {
+  // prevent the window from scrolling
+  event.preventDefault();
+  lastTouch = event.touches[0];
++  recordStartTimeAndPosition(event.touches[0]);
+}, {passive: false});
+canvas.addEventListener('touchsmove', (event) => {
+  lastTouch = event.touches[0];
+});
+```
+
+and with those changes it *seems* like it works to me.
+
+{{{example url="../threejs-indexed-textures-picking-debounced.html" }}}
+
+I'm not a UX expert so I'd love to hear if there is a better
+solution.
+
+I hope that gave you some idea of how indexed graphics can be useful
+and how you can modify the shaders three.js makes to add simple features.
+How to use GLSL, the language the shaders are written in, is too much for
+this article. There are a few links to some info in
+[the article on post processing](threejs-post-processing.html).

+ 1 - 0
threejs/lessons/toc.html

@@ -17,6 +17,7 @@
     <li><a href="/threejs/lessons/threejs-post-processing-3dlut.html">Applying a LUT File for effects</a></li>    
     <li><a href="/threejs/lessons/threejs-shadertoy.html">Using Shadertoy shaders</a></li>    
     <li><a href="/threejs/lessons/threejs-align-html-elements-to-3d.html">Aligning HTML Elements to 3D</a></li>
+    <li><a href="/threejs/lessons/threejs-indexed-textures.html">Using Indexed Textures for Picking and Color</a></li>
   </ul>
   <li>Optimization</li>
   <ul>

BIN
threejs/resources/data/world/country-index-texture.png


+ 432 - 0
threejs/threejs-indexed-textures-picking-and-highlighting.html

@@ -0,0 +1,432 @@
+<!-- Licensed under a BSD license. See license.html for license -->
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
+    <title>Three.js - Indexed Textures - Picking and Highlighting</title>
+    <style>
+    body {
+        margin: 0;
+        font-family: sans-serif;
+    }
+    #c {
+        width: 100%;  /* let our container decide our size */
+        height: 100%;
+        display: block;
+    }
+    #container {
+      position: relative;  /* makes this the origin of its children */
+      width: 100vw;
+      height: 100vh;
+      overflow: hidden;
+    }
+    #labels {
+      position: absolute;  /* let us position ourself inside the container */
+      left: 0;             /* make our position the top left of the container */
+      top: 0;
+      color: white;
+    }
+    #labels>div {
+      position: absolute;  /* let us position them inside the container */
+      left: 0;             /* make their default position the top left of the container */
+      top: 0;
+      cursor: pointer;     /* change the cursor to a hand when over us */
+      font-size: small;
+      user-select: none;   /* don't let the text get selected */
+      pointer-events: none;  /* make us invisible to the pointer */
+      text-shadow:         /* create a black outline */
+        -1px -1px 0 #000,
+         0   -1px 0 #000,
+         1px -1px 0 #000,
+         1px  0   0 #000,
+         1px  1px 0 #000,
+         0    1px 0 #000,
+        -1px  1px 0 #000,
+        -1px  0   0 #000;
+    }
+    #labels>div:hover {
+      color: red;
+    }
+    </style>
+  </head>
+  <body>
+    <div id="container">
+      <canvas id="c"></canvas>
+      <div id="labels"></div>
+    </div>
+  </body>
+<script src="resources/threejs/r102/three.js"></script>
+<script src="resources/threejs/r102/js/utils/BufferGeometryUtils.js"></script>
+<script src="resources/threejs/r102/js/controls/OrbitControls.js"></script>
+<script src="../3rdparty/dat.gui.min.js"></script>
+<script>
+'use strict';
+
+/* global THREE, dat */
+
+function main() {
+  const canvas = document.querySelector('#c');
+  const renderer = new THREE.WebGLRenderer({canvas: canvas});
+
+  const fov = 60;
+  const aspect = 2;  // the canvas default
+  const near = 0.1;
+  const far = 10;
+  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
+  camera.position.z = 2.5;
+
+  const controls = new THREE.OrbitControls(camera, canvas);
+  controls.enableDamping = true;
+  controls.dampingFactor = 0.05;
+  controls.rotateSpeed = 0.1;
+  controls.enablePan = false;
+  controls.minDistance = 1.2;
+  controls.maxDistance = 4;
+  controls.update();
+
+  const scene = new THREE.Scene();
+  scene.background = new THREE.Color('#246');
+
+  const pickingScene = new THREE.Scene();
+  pickingScene.background = new THREE.Color(0);
+
+  const tempColor = new THREE.Color();
+  function get255BasedColor(color) {
+    tempColor.set(color);
+    return tempColor.toArray().map(v => v * 255);
+  }
+
+  const maxNumCountries = 512;
+  const paletteTextureWidth = maxNumCountries;
+  const paletteTextureHeight = 1;
+  const palette = new Uint8Array(paletteTextureWidth * 3);
+  const paletteTexture = new THREE.DataTexture(
+      palette, paletteTextureWidth, paletteTextureHeight, THREE.RGBFormat);
+  paletteTexture.minFilter = THREE.NearestFilter;
+  paletteTexture.magFilter = THREE.NearestFilter;
+
+  const selectedColor = get255BasedColor('red');
+  const unselectedColor = get255BasedColor('#444');
+  const oceanColor = get255BasedColor('rgb(100,200,255)');
+  resetPalette();
+
+  function setPaletteColor(index, color) {
+    palette.set(color, index * 3);
+  }
+
+  function resetPalette() {
+    // make all colors the unselected color
+    for (let i = 1; i < maxNumCountries; ++i) {
+      setPaletteColor(i, unselectedColor);
+    }
+
+    // set the ocean color (index #0)
+    setPaletteColor(0, oceanColor);
+    paletteTexture.needsUpdate = true;
+  }
+
+  {
+    const loader = new THREE.TextureLoader();
+    const geometry = new THREE.SphereBufferGeometry(1, 64, 32);
+
+    const indexTexture = loader.load('resources/data/world/country-index-texture.png', render);
+    indexTexture.minFilter = THREE.NearestFilter;
+    indexTexture.magFilter = THREE.NearestFilter;
+
+    const pickingMaterial = new THREE.MeshBasicMaterial({map: indexTexture});
+    pickingScene.add(new THREE.Mesh(geometry, pickingMaterial));
+
+    const fragmentShaderReplacements = [
+      {
+        from: '#include <common>',
+        to: `
+          #include <common>
+          uniform sampler2D indexTexture;
+          uniform sampler2D paletteTexture;
+          uniform float paletteTextureWidth;
+        `,
+      },
+      {
+        from: '#include <color_fragment>',
+        to: `
+          #include <color_fragment>
+          {
+            vec4 indexColor = texture2D(indexTexture, vUv);
+            float index = indexColor.r * 255.0 + indexColor.g * 255.0 * 256.0;
+            vec2 paletteUV = vec2((index + 0.5) / paletteTextureWidth, 0.5);
+            vec4 paletteColor = texture2D(paletteTexture, paletteUV);
+            // diffuseColor.rgb += paletteColor.rgb;   // white outlines
+            diffuseColor.rgb = paletteColor.rgb - diffuseColor.rgb;  // black outlines
+          }
+        `,
+      },
+    ];
+
+    const texture = loader.load('resources/data/world/country-outlines-4k.png', render);
+    const material = new THREE.MeshBasicMaterial({map: texture});
+    material.onBeforeCompile = function(shader) {
+      fragmentShaderReplacements.forEach((rep) => {
+        shader.fragmentShader = shader.fragmentShader.replace(rep.from, rep.to);
+      });
+      shader.uniforms.paletteTexture = {value: paletteTexture};
+      shader.uniforms.indexTexture = {value: indexTexture};
+      shader.uniforms.paletteTextureWidth = {value: paletteTextureWidth};
+    };
+    scene.add(new THREE.Mesh(geometry, material));
+  }
+
+  async function loadJSON(url) {
+    const req = await fetch(url);
+    return req.json();
+  }
+
+  let numCountriesSelected = 0;
+  let countryInfos;
+  async function loadCountryData() {
+    countryInfos = await loadJSON('resources/data/world/country-info.json');  /* threejsfundamentals: url */
+
+    const lonFudge = Math.PI * 1.5;
+    const latFudge = Math.PI;
+    // these helpers will make it easy to position the boxes
+    // We can rotate the lon helper on its Y axis to the longitude
+    const lonHelper = new THREE.Object3D();
+    // We rotate the latHelper on its X axis to the latitude
+    const latHelper = new THREE.Object3D();
+    lonHelper.add(latHelper);
+    // The position helper moves the object to the edge of the sphere
+    const positionHelper = new THREE.Object3D();
+    positionHelper.position.z = 1;
+    latHelper.add(positionHelper);
+
+    const labelParentElem = document.querySelector('#labels');
+    for (const countryInfo of countryInfos) {
+      const {lat, lon, min, max, name} = countryInfo;
+
+      // adjust the helpers to point to the latitude and longitude
+      lonHelper.rotation.y = THREE.Math.degToRad(lon) + lonFudge;
+      latHelper.rotation.x = THREE.Math.degToRad(lat) + latFudge;
+
+      // get the position of the lat/lon
+      positionHelper.updateWorldMatrix(true, false);
+      const position = new THREE.Vector3();
+      positionHelper.getWorldPosition(position);
+      countryInfo.position = position;
+
+      // compute the area for each country
+      const width = max[0] - min[0];
+      const height = max[1] - min[1];
+      const area = width * height;
+      countryInfo.area = area;
+
+      // add an element for each country
+      const elem = document.createElement('div');
+      elem.textContent = name;
+      labelParentElem.appendChild(elem);
+      countryInfo.elem = elem;
+    }
+    requestRenderIfNotRequested();
+  }
+  loadCountryData();
+
+  const tempV = new THREE.Vector3();
+  const normalMatrix = new THREE.Matrix3();
+  const positiveZ = new THREE.Vector3(0, 0, 1);
+
+  const settings = {
+    minArea: 20,
+    visibleAngleDeg: 75,
+  };
+  const gui = new dat.GUI({width: 300});
+  gui.add(settings, 'minArea', 0, 50).onChange(requestRenderIfNotRequested);
+  gui.add(settings, 'visibleAngleDeg', 0, 180).onChange(requestRenderIfNotRequested);
+
+  function updateLabels() {
+    // exit if we have not loaded the data yet
+    if (!countryInfos) {
+      return;
+    }
+
+    const large = settings.minArea * settings.minArea;
+    const visibleDot = Math.cos(THREE.Math.degToRad(settings.visibleAngleDeg));
+    // get a matrix that represents a relative orientation of the camera
+    normalMatrix.getNormalMatrix(camera.matrixWorldInverse);
+    for (const countryInfo of countryInfos) {
+      const {position, elem, area, selected} = countryInfo;
+      const largeEnough = area >= large;
+      const show = selected || (numCountriesSelected === 0 && largeEnough);
+      if (!show) {
+        elem.style.display = 'none';
+        continue;
+      }
+
+      // orient the position based on the camera's orientation
+      tempV.copy(position);
+      tempV.applyMatrix3(normalMatrix);
+
+      // get the dot product with positiveZ
+      // -1 = facing directly away and +1 = facing directly toward us
+      const dot = tempV.dot(positiveZ);
+
+      // if the orientation is not facing us hide it.
+      if (dot < visibleDot) {
+        elem.style.display = 'none';
+        continue;
+      }
+
+      // restore the element to its default display style
+      elem.style.display = '';
+
+      // get the normalized screen coordinate of that position
+      // x and y will be in the -1 to +1 range with x = -1 being
+      // on the left and y = -1 being on the bottom
+      tempV.copy(position);
+      tempV.project(camera);
+
+      // convert the normalized position to CSS coordinates
+      const x = (tempV.x *  .5 + .5) * canvas.clientWidth;
+      const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
+
+      // move the elem to that position
+      countryInfo.elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
+    }
+  }
+
+  class GPUPickHelper {
+    constructor() {
+      // create a 1x1 pixel render target
+      this.pickingTexture = new THREE.WebGLRenderTarget(1, 1);
+      this.pixelBuffer = new Uint8Array(4);
+    }
+    pick(cssPosition, scene, camera) {
+      const {pickingTexture, pixelBuffer} = this;
+
+      // set the view offset to represent just a single pixel under the mouse
+      const pixelRatio = renderer.getPixelRatio();
+      camera.setViewOffset(
+          renderer.context.drawingBufferWidth,   // full width
+          renderer.context.drawingBufferHeight,  // full top
+          cssPosition.x * pixelRatio | 0,        // rect x
+          cssPosition.y * pixelRatio | 0,        // rect y
+          1,                                     // rect width
+          1,                                     // rect height
+      );
+      // render the scene
+      renderer.setRenderTarget(pickingTexture);
+      renderer.render(scene, camera);
+      renderer.setRenderTarget(null);
+      // clear the view offset so rendering returns to normal
+      camera.clearViewOffset();
+      //read the pixel
+      renderer.readRenderTargetPixels(
+          pickingTexture,
+          0,   // x
+          0,   // y
+          1,   // width
+          1,   // height
+          pixelBuffer);
+
+      const id =
+          (pixelBuffer[0] <<  0) |
+          (pixelBuffer[1] <<  8) |
+          (pixelBuffer[2] << 16);
+
+      return id;
+    }
+  }
+
+  const pickHelper = new GPUPickHelper();
+
+  function pickCountry(event) {
+    // exit if we have not loaded the data yet
+    if (!countryInfos) {
+      return;
+    }
+
+    const position = {x: event.clientX, y: event.clientY};
+    const id = pickHelper.pick(position, pickingScene, camera);
+    if (id > 0) {
+      const countryInfo = countryInfos[id - 1];
+      const selected = !countryInfo.selected;
+      if (selected && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
+        unselectAllCountries();
+      }
+      numCountriesSelected += selected ? 1 : -1;
+      countryInfo.selected = selected;
+      setPaletteColor(id, selected ? selectedColor : unselectedColor);
+      paletteTexture.needsUpdate = true;
+    } else if (numCountriesSelected) {
+      unselectAllCountries();
+    }
+    requestRenderIfNotRequested();
+  }
+
+  function unselectAllCountries() {
+    numCountriesSelected = 0;
+    countryInfos.forEach((countryInfo) => {
+      countryInfo.selected = false;
+    });
+    resetPalette();
+  }
+
+  canvas.addEventListener('mouseup', pickCountry);
+
+  let lastTouch;
+  canvas.addEventListener('touchstart', (event) => {
+    // prevent the window from scrolling
+    event.preventDefault();
+    lastTouch = event.touches[0];
+  }, {passive: false});
+  canvas.addEventListener('touchsmove', (event) => {
+    lastTouch = event.touches[0];
+  });
+  canvas.addEventListener('touchend', () => {
+    pickCountry(lastTouch);
+  });
+
+  function resizeRendererToDisplaySize(renderer) {
+    const canvas = renderer.domElement;
+    const width = canvas.clientWidth;
+    const height = canvas.clientHeight;
+    const needResize = canvas.width !== width || canvas.height !== height;
+    if (needResize) {
+      renderer.setSize(width, height, false);
+    }
+    return needResize;
+  }
+
+  let renderRequested = false;
+
+  function render() {
+    renderRequested = undefined;
+
+    if (resizeRendererToDisplaySize(renderer)) {
+      const canvas = renderer.domElement;
+      camera.aspect = canvas.clientWidth / canvas.clientHeight;
+      camera.updateProjectionMatrix();
+    }
+
+    controls.update();
+
+
+    updateLabels();
+
+    renderer.render(scene, camera);
+  }
+  render();
+
+  function requestRenderIfNotRequested() {
+    if (!renderRequested) {
+      renderRequested = true;
+      requestAnimationFrame(render);
+    }
+  }
+
+  controls.addEventListener('change', requestRenderIfNotRequested);
+  window.addEventListener('resize', requestRenderIfNotRequested);
+}
+
+main();
+</script>
+</html>
+

+ 459 - 0
threejs/threejs-indexed-textures-picking-debounced.html

@@ -0,0 +1,459 @@
+<!-- Licensed under a BSD license. See license.html for license -->
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
+    <title>Three.js - Indexed Textures - Picking and Highlighting - Debounced</title>
+    <style>
+    body {
+        margin: 0;
+        font-family: sans-serif;
+    }
+    #c {
+        width: 100%;  /* let our container decide our size */
+        height: 100%;
+        display: block;
+    }
+    #container {
+      position: relative;  /* makes this the origin of its children */
+      width: 100vw;
+      height: 100vh;
+      overflow: hidden;
+    }
+    #labels {
+      position: absolute;  /* let us position ourself inside the container */
+      left: 0;             /* make our position the top left of the container */
+      top: 0;
+      color: white;
+    }
+    #labels>div {
+      position: absolute;  /* let us position them inside the container */
+      left: 0;             /* make their default position the top left of the container */
+      top: 0;
+      cursor: pointer;     /* change the cursor to a hand when over us */
+      font-size: small;
+      user-select: none;   /* don't let the text get selected */
+      pointer-events: none;  /* make us invisible to the pointer */
+      text-shadow:         /* create a black outline */
+        -1px -1px 0 #000,
+         0   -1px 0 #000,
+         1px -1px 0 #000,
+         1px  0   0 #000,
+         1px  1px 0 #000,
+         0    1px 0 #000,
+        -1px  1px 0 #000,
+        -1px  0   0 #000;
+    }
+    #labels>div:hover {
+      color: red;
+    }
+    </style>
+  </head>
+  <body>
+    <div id="container">
+      <canvas id="c"></canvas>
+      <div id="labels"></div>
+    </div>
+  </body>
+<script src="resources/threejs/r102/three.js"></script>
+<script src="resources/threejs/r102/js/utils/BufferGeometryUtils.js"></script>
+<script src="resources/threejs/r102/js/controls/OrbitControls.js"></script>
+<script src="../3rdparty/dat.gui.min.js"></script>
+<script>
+'use strict';
+
+/* global THREE, dat */
+
+function main() {
+  const canvas = document.querySelector('#c');
+  const renderer = new THREE.WebGLRenderer({canvas: canvas});
+
+  const fov = 60;
+  const aspect = 2;  // the canvas default
+  const near = 0.1;
+  const far = 10;
+  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
+  camera.position.z = 2.5;
+
+  const controls = new THREE.OrbitControls(camera, canvas);
+  controls.enableDamping = true;
+  controls.dampingFactor = 0.05;
+  controls.rotateSpeed = 0.1;
+  controls.enablePan = false;
+  controls.minDistance = 1.2;
+  controls.maxDistance = 4;
+  controls.update();
+
+  const scene = new THREE.Scene();
+  scene.background = new THREE.Color('#246');
+
+  const pickingScene = new THREE.Scene();
+  pickingScene.background = new THREE.Color(0);
+
+  const tempColor = new THREE.Color();
+  function get255BasedColor(color) {
+    tempColor.set(color);
+    return tempColor.toArray().map(v => v * 255);
+  }
+
+  const maxNumCountries = 512;
+  const paletteTextureWidth = maxNumCountries;
+  const paletteTextureHeight = 1;
+  const palette = new Uint8Array(paletteTextureWidth * 3);
+  const paletteTexture = new THREE.DataTexture(
+      palette, paletteTextureWidth, paletteTextureHeight, THREE.RGBFormat);
+  paletteTexture.minFilter = THREE.NearestFilter;
+  paletteTexture.magFilter = THREE.NearestFilter;
+
+  const selectedColor = get255BasedColor('red');
+  const unselectedColor = get255BasedColor('#444');
+  const oceanColor = get255BasedColor('rgb(100,200,255)');
+  resetPalette();
+
+  function setPaletteColor(index, color) {
+    palette.set(color, index * 3);
+  }
+
+  function resetPalette() {
+    // make all colors the unselected color
+    for (let i = 1; i < maxNumCountries; ++i) {
+      setPaletteColor(i, unselectedColor);
+    }
+
+    // set the ocean color (index #0)
+    setPaletteColor(0, oceanColor);
+    paletteTexture.needsUpdate = true;
+  }
+
+  {
+    const loader = new THREE.TextureLoader();
+    const geometry = new THREE.SphereBufferGeometry(1, 64, 32);
+
+    const indexTexture = loader.load('resources/data/world/country-index-texture.png', render);
+    indexTexture.minFilter = THREE.NearestFilter;
+    indexTexture.magFilter = THREE.NearestFilter;
+
+    const pickingMaterial = new THREE.MeshBasicMaterial({map: indexTexture});
+    pickingScene.add(new THREE.Mesh(geometry, pickingMaterial));
+
+    const fragmentShaderReplacements = [
+      {
+        from: '#include <common>',
+        to: `
+          #include <common>
+          uniform sampler2D indexTexture;
+          uniform sampler2D paletteTexture;
+          uniform float paletteTextureWidth;
+        `,
+      },
+      {
+        from: '#include <color_fragment>',
+        to: `
+          #include <color_fragment>
+          {
+            vec4 indexColor = texture2D(indexTexture, vUv);
+            float index = indexColor.r * 255.0 + indexColor.g * 255.0 * 256.0;
+            vec2 paletteUV = vec2((index + 0.5) / paletteTextureWidth, 0.5);
+            vec4 paletteColor = texture2D(paletteTexture, paletteUV);
+            // diffuseColor.rgb += paletteColor.rgb;   // white outlines
+            diffuseColor.rgb = paletteColor.rgb - diffuseColor.rgb;  // black outlines
+          }
+        `,
+      },
+    ];
+
+    const texture = loader.load('resources/data/world/country-outlines-4k.png', render);
+    const material = new THREE.MeshBasicMaterial({map: texture});
+    material.onBeforeCompile = function(shader) {
+      fragmentShaderReplacements.forEach((rep) => {
+        shader.fragmentShader = shader.fragmentShader.replace(rep.from, rep.to);
+      });
+      shader.uniforms.paletteTexture = {value: paletteTexture};
+      shader.uniforms.indexTexture = {value: indexTexture};
+      shader.uniforms.paletteTextureWidth = {value: paletteTextureWidth};
+    };
+    scene.add(new THREE.Mesh(geometry, material));
+  }
+
+  async function loadJSON(url) {
+    const req = await fetch(url);
+    return req.json();
+  }
+
+  let numCountriesSelected = 0;
+  let countryInfos;
+  async function loadCountryData() {
+    countryInfos = await loadJSON('resources/data/world/country-info.json');  /* threejsfundamentals: url */
+
+    const lonFudge = Math.PI * 1.5;
+    const latFudge = Math.PI;
+    // these helpers will make it easy to position the boxes
+    // We can rotate the lon helper on its Y axis to the longitude
+    const lonHelper = new THREE.Object3D();
+    // We rotate the latHelper on its X axis to the latitude
+    const latHelper = new THREE.Object3D();
+    lonHelper.add(latHelper);
+    // The position helper moves the object to the edge of the sphere
+    const positionHelper = new THREE.Object3D();
+    positionHelper.position.z = 1;
+    latHelper.add(positionHelper);
+
+    const labelParentElem = document.querySelector('#labels');
+    for (const countryInfo of countryInfos) {
+      const {lat, lon, min, max, name} = countryInfo;
+
+      // adjust the helpers to point to the latitude and longitude
+      lonHelper.rotation.y = THREE.Math.degToRad(lon) + lonFudge;
+      latHelper.rotation.x = THREE.Math.degToRad(lat) + latFudge;
+
+      // get the position of the lat/lon
+      positionHelper.updateWorldMatrix(true, false);
+      const position = new THREE.Vector3();
+      positionHelper.getWorldPosition(position);
+      countryInfo.position = position;
+
+      // compute the area for each country
+      const width = max[0] - min[0];
+      const height = max[1] - min[1];
+      const area = width * height;
+      countryInfo.area = area;
+
+      // add an element for each country
+      const elem = document.createElement('div');
+      elem.textContent = name;
+      labelParentElem.appendChild(elem);
+      countryInfo.elem = elem;
+    }
+    requestRenderIfNotRequested();
+  }
+  loadCountryData();
+
+  const tempV = new THREE.Vector3();
+  const normalMatrix = new THREE.Matrix3();
+  const positiveZ = new THREE.Vector3(0, 0, 1);
+
+  const settings = {
+    minArea: 20,
+    visibleAngleDeg: 75,
+  };
+  const gui = new dat.GUI({width: 300});
+  gui.add(settings, 'minArea', 0, 50).onChange(requestRenderIfNotRequested);
+  gui.add(settings, 'visibleAngleDeg', 0, 180).onChange(requestRenderIfNotRequested);
+
+  function updateLabels() {
+    // exit if we have not loaded the data yet
+    if (!countryInfos) {
+      return;
+    }
+
+    const large = settings.minArea * settings.minArea;
+    const visibleDot = Math.cos(THREE.Math.degToRad(settings.visibleAngleDeg));
+    // get a matrix that represents a relative orientation of the camera
+    normalMatrix.getNormalMatrix(camera.matrixWorldInverse);
+    for (const countryInfo of countryInfos) {
+      const {position, elem, area, selected} = countryInfo;
+      const largeEnough = area >= large;
+      const show = selected || (numCountriesSelected === 0 && largeEnough);
+      if (!show) {
+        elem.style.display = 'none';
+        continue;
+      }
+
+      // orient the position based on the camera's orientation
+      tempV.copy(position);
+      tempV.applyMatrix3(normalMatrix);
+
+      // get the dot product with positiveZ
+      // -1 = facing directly away and +1 = facing directly toward us
+      const dot = tempV.dot(positiveZ);
+
+      // if the orientation is not facing us hide it.
+      if (dot < visibleDot) {
+        elem.style.display = 'none';
+        continue;
+      }
+
+      // restore the element to its default display style
+      elem.style.display = '';
+
+      // get the normalized screen coordinate of that position
+      // x and y will be in the -1 to +1 range with x = -1 being
+      // on the left and y = -1 being on the bottom
+      tempV.copy(position);
+      tempV.project(camera);
+
+      // convert the normalized position to CSS coordinates
+      const x = (tempV.x *  .5 + .5) * canvas.clientWidth;
+      const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
+
+      // move the elem to that position
+      countryInfo.elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
+    }
+  }
+
+  class GPUPickHelper {
+    constructor() {
+      // create a 1x1 pixel render target
+      this.pickingTexture = new THREE.WebGLRenderTarget(1, 1);
+      this.pixelBuffer = new Uint8Array(4);
+    }
+    pick(cssPosition, scene, camera) {
+      const {pickingTexture, pixelBuffer} = this;
+
+      // set the view offset to represent just a single pixel under the mouse
+      const pixelRatio = renderer.getPixelRatio();
+      camera.setViewOffset(
+          renderer.context.drawingBufferWidth,   // full width
+          renderer.context.drawingBufferHeight,  // full top
+          cssPosition.x * pixelRatio | 0,        // rect x
+          cssPosition.y * pixelRatio | 0,        // rect y
+          1,                                     // rect width
+          1,                                     // rect height
+      );
+      // render the scene
+      renderer.setRenderTarget(pickingTexture);
+      renderer.render(scene, camera);
+      renderer.setRenderTarget(null);
+      // clear the view offset so rendering returns to normal
+      camera.clearViewOffset();
+      //read the pixel
+      renderer.readRenderTargetPixels(
+          pickingTexture,
+          0,   // x
+          0,   // y
+          1,   // width
+          1,   // height
+          pixelBuffer);
+
+      const id =
+          (pixelBuffer[0] <<  0) |
+          (pixelBuffer[1] <<  8) |
+          (pixelBuffer[2] << 16);
+
+      return id;
+    }
+  }
+
+  const pickHelper = new GPUPickHelper();
+
+  const maxClickTimeMs = 200;
+  const maxMoveDeltaSq = 5 * 5;
+  const startPosition = {};
+  let startTimeMs;
+  function recordStartTimeAndPosition(event) {
+    startTimeMs = performance.now();
+    startPosition.x = event.clientX;
+    startPosition.y = event.clientY;
+  }
+
+  function pickCountry(event) {
+    // exit if we have not loaded the data yet
+    if (!countryInfos) {
+      return;
+    }
+
+    // if it's been a moment since the user started
+    // then assume it was a drag action, not a select action
+    const clickTimeMs = performance.now() - startTimeMs;
+    if (clickTimeMs > maxClickTimeMs) {
+      return;
+    }
+
+    // if they moved assume it was a drag action
+    const moveDeltaSq = (startPosition.x - event.clientX) ** 2 +
+                        (startPosition.y - event.clientY) ** 2;
+    if (moveDeltaSq > maxMoveDeltaSq) {
+      return;
+    }
+
+
+    const position = {x: event.clientX, y: event.clientY};
+    const id = pickHelper.pick(position, pickingScene, camera);
+    if (id > 0) {
+      const countryInfo = countryInfos[id - 1];
+      const selected = !countryInfo.selected;
+      if (selected && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
+        unselectAllCountries();
+      }
+      numCountriesSelected += selected ? 1 : -1;
+      countryInfo.selected = selected;
+      setPaletteColor(id, selected ? selectedColor : unselectedColor);
+      paletteTexture.needsUpdate = true;
+    } else if (numCountriesSelected) {
+      unselectAllCountries();
+    }
+    requestRenderIfNotRequested();
+  }
+
+  function unselectAllCountries() {
+    numCountriesSelected = 0;
+    countryInfos.forEach((countryInfo) => {
+      countryInfo.selected = false;
+    });
+    resetPalette();
+  }
+
+  canvas.addEventListener('mousedown', recordStartTimeAndPosition);
+  canvas.addEventListener('mouseup', pickCountry);
+
+  let lastTouch;
+  canvas.addEventListener('touchstart', (event) => {
+    // prevent the window from scrolling
+    event.preventDefault();
+    lastTouch = event.touches[0];
+    recordStartTimeAndPosition(event.touches[0]);
+  }, {passive: false});
+  canvas.addEventListener('touchsmove', (event) => {
+    lastTouch = event.touches[0];
+  });
+  canvas.addEventListener('touchend', () => {
+    pickCountry(lastTouch);
+  });
+
+  function resizeRendererToDisplaySize(renderer) {
+    const canvas = renderer.domElement;
+    const width = canvas.clientWidth;
+    const height = canvas.clientHeight;
+    const needResize = canvas.width !== width || canvas.height !== height;
+    if (needResize) {
+      renderer.setSize(width, height, false);
+    }
+    return needResize;
+  }
+
+  let renderRequested = false;
+
+  function render() {
+    renderRequested = undefined;
+
+    if (resizeRendererToDisplaySize(renderer)) {
+      const canvas = renderer.domElement;
+      camera.aspect = canvas.clientWidth / canvas.clientHeight;
+      camera.updateProjectionMatrix();
+    }
+
+    controls.update();
+
+
+    updateLabels();
+
+    renderer.render(scene, camera);
+  }
+  render();
+
+  function requestRenderIfNotRequested() {
+    if (!renderRequested) {
+      renderRequested = true;
+      requestAnimationFrame(render);
+    }
+  }
+
+  controls.addEventListener('change', requestRenderIfNotRequested);
+  window.addEventListener('resize', requestRenderIfNotRequested);
+}
+
+main();
+</script>
+</html>
+

+ 363 - 0
threejs/threejs-indexed-textures-picking.html

@@ -0,0 +1,363 @@
+<!-- Licensed under a BSD license. See license.html for license -->
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
+    <title>Three.js - Indexed Textures - Picking</title>
+    <style>
+    body {
+        margin: 0;
+        font-family: sans-serif;
+    }
+    #c {
+        width: 100%;  /* let our container decide our size */
+        height: 100%;
+        display: block;
+    }
+    #container {
+      position: relative;  /* makes this the origin of its children */
+      width: 100vw;
+      height: 100vh;
+      overflow: hidden;
+    }
+    #labels {
+      position: absolute;  /* let us position ourself inside the container */
+      left: 0;             /* make our position the top left of the container */
+      top: 0;
+      color: white;
+    }
+    #labels>div {
+      position: absolute;  /* let us position them inside the container */
+      left: 0;             /* make their default position the top left of the container */
+      top: 0;
+      cursor: pointer;     /* change the cursor to a hand when over us */
+      font-size: small;
+      user-select: none;   /* don't let the text get selected */
+      pointer-events: none;  /* make us invisible to the pointer */
+      text-shadow:         /* create a black outline */
+        -1px -1px 0 #000,
+         0   -1px 0 #000,
+         1px -1px 0 #000,
+         1px  0   0 #000,
+         1px  1px 0 #000,
+         0    1px 0 #000,
+        -1px  1px 0 #000,
+        -1px  0   0 #000;
+    }
+    #labels>div:hover {
+      color: red;
+    }
+    </style>
+  </head>
+  <body>
+    <div id="container">
+      <canvas id="c"></canvas>
+      <div id="labels"></div>
+    </div>
+  </body>
+<script src="resources/threejs/r102/three.js"></script>
+<script src="resources/threejs/r102/js/utils/BufferGeometryUtils.js"></script>
+<script src="resources/threejs/r102/js/controls/OrbitControls.js"></script>
+<script src="../3rdparty/dat.gui.min.js"></script>
+<script>
+'use strict';
+
+/* global THREE, dat */
+
+function main() {
+  const canvas = document.querySelector('#c');
+  const renderer = new THREE.WebGLRenderer({canvas: canvas});
+
+  const fov = 60;
+  const aspect = 2;  // the canvas default
+  const near = 0.1;
+  const far = 10;
+  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
+  camera.position.z = 2.5;
+
+  const controls = new THREE.OrbitControls(camera, canvas);
+  controls.enableDamping = true;
+  controls.dampingFactor = 0.05;
+  controls.rotateSpeed = 0.1;
+  controls.enablePan = false;
+  controls.minDistance = 1.2;
+  controls.maxDistance = 4;
+  controls.update();
+
+  const scene = new THREE.Scene();
+  scene.background = new THREE.Color('#246');
+
+  const pickingScene = new THREE.Scene();
+  pickingScene.background = new THREE.Color(0);
+
+  {
+    const loader = new THREE.TextureLoader();
+    const geometry = new THREE.SphereBufferGeometry(1, 64, 32);
+
+    const indexTexture = loader.load('resources/data/world/country-index-texture.png', render);
+    indexTexture.minFilter = THREE.NearestFilter;
+    indexTexture.magFilter = THREE.NearestFilter;
+
+    const pickingMaterial = new THREE.MeshBasicMaterial({map: indexTexture});
+    pickingScene.add(new THREE.Mesh(geometry, pickingMaterial));
+
+    const texture = loader.load('resources/data/world/country-outlines-4k.png', render);
+    const material = new THREE.MeshBasicMaterial({map: texture});
+    scene.add(new THREE.Mesh(geometry, material));
+  }
+
+  async function loadJSON(url) {
+    const req = await fetch(url);
+    return req.json();
+  }
+
+  let numCountriesSelected = 0;
+  let countryInfos;
+  async function loadCountryData() {
+    countryInfos = await loadJSON('resources/data/world/country-info.json');  /* threejsfundamentals: url */
+
+    const lonFudge = Math.PI * 1.5;
+    const latFudge = Math.PI;
+    // these helpers will make it easy to position the boxes
+    // We can rotate the lon helper on its Y axis to the longitude
+    const lonHelper = new THREE.Object3D();
+    // We rotate the latHelper on its X axis to the latitude
+    const latHelper = new THREE.Object3D();
+    lonHelper.add(latHelper);
+    // The position helper moves the object to the edge of the sphere
+    const positionHelper = new THREE.Object3D();
+    positionHelper.position.z = 1;
+    latHelper.add(positionHelper);
+
+    const labelParentElem = document.querySelector('#labels');
+    for (const countryInfo of countryInfos) {
+      const {lat, lon, min, max, name} = countryInfo;
+
+      // adjust the helpers to point to the latitude and longitude
+      lonHelper.rotation.y = THREE.Math.degToRad(lon) + lonFudge;
+      latHelper.rotation.x = THREE.Math.degToRad(lat) + latFudge;
+
+      // get the position of the lat/lon
+      positionHelper.updateWorldMatrix(true, false);
+      const position = new THREE.Vector3();
+      positionHelper.getWorldPosition(position);
+      countryInfo.position = position;
+
+      // compute the area for each country
+      const width = max[0] - min[0];
+      const height = max[1] - min[1];
+      const area = width * height;
+      countryInfo.area = area;
+
+      // add an element for each country
+      const elem = document.createElement('div');
+      elem.textContent = name;
+      labelParentElem.appendChild(elem);
+      countryInfo.elem = elem;
+    }
+    requestRenderIfNotRequested();
+  }
+  loadCountryData();
+
+  const tempV = new THREE.Vector3();
+  const normalMatrix = new THREE.Matrix3();
+  const positiveZ = new THREE.Vector3(0, 0, 1);
+
+  const settings = {
+    minArea: 20,
+    visibleAngleDeg: 75,
+  };
+  const gui = new dat.GUI({width: 300});
+  gui.add(settings, 'minArea', 0, 50).onChange(requestRenderIfNotRequested);
+  gui.add(settings, 'visibleAngleDeg', 0, 180).onChange(requestRenderIfNotRequested);
+
+  function updateLabels() {
+    // exit if we have not loaded the data yet
+    if (!countryInfos) {
+      return;
+    }
+
+    const large = settings.minArea * settings.minArea;
+    const visibleDot = Math.cos(THREE.Math.degToRad(settings.visibleAngleDeg));
+    // get a matrix that represents a relative orientation of the camera
+    normalMatrix.getNormalMatrix(camera.matrixWorldInverse);
+    for (const countryInfo of countryInfos) {
+      const {position, elem, area, selected} = countryInfo;
+      const largeEnough = area >= large;
+      const show = selected || (numCountriesSelected === 0 && largeEnough);
+      if (!show) {
+        elem.style.display = 'none';
+        continue;
+      }
+
+      // orient the position based on the camera's orientation
+      tempV.copy(position);
+      tempV.applyMatrix3(normalMatrix);
+
+      // get the dot product with positiveZ
+      // -1 = facing directly away and +1 = facing directly toward us
+      const dot = tempV.dot(positiveZ);
+
+      // if the orientation is not facing us hide it.
+      if (dot < visibleDot) {
+        elem.style.display = 'none';
+        continue;
+      }
+
+      // restore the element to its default display style
+      elem.style.display = '';
+
+      // get the normalized screen coordinate of that position
+      // x and y will be in the -1 to +1 range with x = -1 being
+      // on the left and y = -1 being on the bottom
+      tempV.copy(position);
+      tempV.project(camera);
+
+      // convert the normalized position to CSS coordinates
+      const x = (tempV.x *  .5 + .5) * canvas.clientWidth;
+      const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
+
+      // move the elem to that position
+      countryInfo.elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
+    }
+  }
+
+  class GPUPickHelper {
+    constructor() {
+      // create a 1x1 pixel render target
+      this.pickingTexture = new THREE.WebGLRenderTarget(1, 1);
+      this.pixelBuffer = new Uint8Array(4);
+    }
+    pick(cssPosition, scene, camera) {
+      const {pickingTexture, pixelBuffer} = this;
+
+      // set the view offset to represent just a single pixel under the mouse
+      const pixelRatio = renderer.getPixelRatio();
+      camera.setViewOffset(
+          renderer.context.drawingBufferWidth,   // full width
+          renderer.context.drawingBufferHeight,  // full top
+          cssPosition.x * pixelRatio | 0,        // rect x
+          cssPosition.y * pixelRatio | 0,        // rect y
+          1,                                     // rect width
+          1,                                     // rect height
+      );
+      // render the scene
+      renderer.setRenderTarget(pickingTexture);
+      renderer.render(scene, camera);
+      renderer.setRenderTarget(null);
+      // clear the view offset so rendering returns to normal
+      camera.clearViewOffset();
+      //read the pixel
+      renderer.readRenderTargetPixels(
+          pickingTexture,
+          0,   // x
+          0,   // y
+          1,   // width
+          1,   // height
+          pixelBuffer);
+
+      const id =
+          (pixelBuffer[0] <<  0) |
+          (pixelBuffer[1] <<  8) |
+          (pixelBuffer[2] << 16);
+
+      return id;
+    }
+  }
+
+  const pickHelper = new GPUPickHelper();
+
+  function pickCountry(event) {
+    // exit if we have not loaded the data yet
+    if (!countryInfos) {
+      return;
+    }
+
+    const position = {x: event.clientX, y: event.clientY};
+    const id = pickHelper.pick(position, pickingScene, camera);
+    if (id > 0) {
+      // we clicked a country. Toggle its 'selected' property
+      const countryInfo = countryInfos[id - 1];
+      const selected = !countryInfo.selected;
+      // if we're selecting this country and modifiers are not
+      // pressed unselect everything else.
+      if (selected && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
+        unselectAllCountries();
+      }
+      numCountriesSelected += selected ? 1 : -1;
+      countryInfo.selected = selected;
+    } else if (numCountriesSelected) {
+      unselectAllCountries();
+    }
+    requestRenderIfNotRequested();
+  }
+
+  function unselectAllCountries() {
+    numCountriesSelected = 0;
+    countryInfos.forEach((countryInfo) => {
+      countryInfo.selected = false;
+    });
+  }
+
+  canvas.addEventListener('mouseup', pickCountry);
+
+  let lastTouch;
+  canvas.addEventListener('touchstart', (event) => {
+    // prevent the window from scrolling
+    event.preventDefault();
+    lastTouch = event.touches[0];
+  }, {passive: false});
+  canvas.addEventListener('touchsmove', (event) => {
+    lastTouch = event.touches[0];
+  });
+  canvas.addEventListener('touchend', () => {
+    pickCountry(lastTouch);
+  });
+
+  function resizeRendererToDisplaySize(renderer) {
+    const canvas = renderer.domElement;
+    const width = canvas.clientWidth;
+    const height = canvas.clientHeight;
+    const needResize = canvas.width !== width || canvas.height !== height;
+    if (needResize) {
+      renderer.setSize(width, height, false);
+    }
+    return needResize;
+  }
+
+  let renderRequested = false;
+
+  function render() {
+    renderRequested = undefined;
+
+    if (resizeRendererToDisplaySize(renderer)) {
+      const canvas = renderer.domElement;
+      camera.aspect = canvas.clientWidth / canvas.clientHeight;
+      camera.updateProjectionMatrix();
+    }
+
+    controls.update();
+
+
+    updateLabels();
+
+    renderer.render(scene, camera);
+  }
+  render();
+
+  function requestRenderIfNotRequested() {
+    if (!renderRequested) {
+      renderRequested = true;
+      requestAnimationFrame(render);
+    }
+  }
+
+  controls.addEventListener('change', requestRenderIfNotRequested);
+  window.addEventListener('resize', requestRenderIfNotRequested);
+}
+
+main();
+</script>
+</html>
+

+ 409 - 0
threejs/threejs-indexed-textures-random-colors.html

@@ -0,0 +1,409 @@
+<!-- Licensed under a BSD license. See license.html for license -->
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
+    <title>Three.js - Indexed Textures - Random Colors</title>
+    <style>
+    body {
+        margin: 0;
+        font-family: sans-serif;
+    }
+    #c {
+        width: 100%;  /* let our container decide our size */
+        height: 100%;
+        display: block;
+    }
+    #container {
+      position: relative;  /* makes this the origin of its children */
+      width: 100vw;
+      height: 100vh;
+      overflow: hidden;
+    }
+    #labels {
+      position: absolute;  /* let us position ourself inside the container */
+      left: 0;             /* make our position the top left of the container */
+      top: 0;
+      color: white;
+    }
+    #labels>div {
+      position: absolute;  /* let us position them inside the container */
+      left: 0;             /* make their default position the top left of the container */
+      top: 0;
+      cursor: pointer;     /* change the cursor to a hand when over us */
+      font-size: small;
+      user-select: none;   /* don't let the text get selected */
+      pointer-events: none;  /* make us invisible to the pointer */
+      text-shadow:         /* create a black outline */
+        -1px -1px 0 #000,
+         0   -1px 0 #000,
+         1px -1px 0 #000,
+         1px  0   0 #000,
+         1px  1px 0 #000,
+         0    1px 0 #000,
+        -1px  1px 0 #000,
+        -1px  0   0 #000;
+    }
+    #labels>div:hover {
+      color: red;
+    }
+    </style>
+  </head>
+  <body>
+    <div id="container">
+      <canvas id="c"></canvas>
+      <div id="labels"></div>
+    </div>
+  </body>
+<script src="resources/threejs/r102/three.js"></script>
+<script src="resources/threejs/r102/js/utils/BufferGeometryUtils.js"></script>
+<script src="resources/threejs/r102/js/controls/OrbitControls.js"></script>
+<script src="../3rdparty/dat.gui.min.js"></script>
+<script>
+'use strict';
+
+/* global THREE, dat */
+
+function main() {
+  const canvas = document.querySelector('#c');
+  const renderer = new THREE.WebGLRenderer({canvas: canvas});
+
+  const fov = 60;
+  const aspect = 2;  // the canvas default
+  const near = 0.1;
+  const far = 10;
+  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
+  camera.position.z = 2.5;
+
+  const controls = new THREE.OrbitControls(camera, canvas);
+  controls.enableDamping = true;
+  controls.dampingFactor = 0.05;
+  controls.rotateSpeed = 0.1;
+  controls.enablePan = false;
+  controls.minDistance = 1.2;
+  controls.maxDistance = 4;
+  controls.update();
+
+  const scene = new THREE.Scene();
+  scene.background = new THREE.Color('#246');
+
+  const pickingScene = new THREE.Scene();
+  pickingScene.background = new THREE.Color(0);
+
+  const maxNumCountries = 512;
+  const paletteTextureWidth = maxNumCountries;
+  const paletteTextureHeight = 1;
+  const palette = new Uint8Array(paletteTextureWidth * 3);
+  const paletteTexture = new THREE.DataTexture(
+      palette, paletteTextureWidth, paletteTextureHeight, THREE.RGBFormat);
+  paletteTexture.minFilter = THREE.NearestFilter;
+  paletteTexture.magFilter = THREE.NearestFilter;
+  for (let i = 1; i < palette.length; ++i) {
+    palette[i] = Math.random() * 256;
+  }
+  // set the ocean color (index #0)
+  palette.set([100, 200, 255], 0);
+  paletteTexture.needsUpdate = true;
+
+  {
+    const loader = new THREE.TextureLoader();
+    const geometry = new THREE.SphereBufferGeometry(1, 64, 32);
+
+    const indexTexture = loader.load('resources/data/world/country-index-texture.png', render);
+    indexTexture.minFilter = THREE.NearestFilter;
+    indexTexture.magFilter = THREE.NearestFilter;
+
+    const pickingMaterial = new THREE.MeshBasicMaterial({map: indexTexture});
+    pickingScene.add(new THREE.Mesh(geometry, pickingMaterial));
+
+    const fragmentShaderReplacements = [
+      {
+        from: '#include <common>',
+        to: `
+          #include <common>
+          uniform sampler2D indexTexture;
+          uniform sampler2D paletteTexture;
+          uniform float paletteTextureWidth;
+        `,
+      },
+      {
+        from: '#include <color_fragment>',
+        to: `
+          #include <color_fragment>
+          {
+            vec4 indexColor = texture2D(indexTexture, vUv);
+            float index = indexColor.r * 255.0 + indexColor.g * 255.0 * 256.0;
+            vec2 paletteUV = vec2((index + 0.5) / paletteTextureWidth, 0.5);
+            vec4 paletteColor = texture2D(paletteTexture, paletteUV);
+            // diffuseColor.rgb += paletteColor.rgb;   // white outlines
+            diffuseColor.rgb = paletteColor.rgb - diffuseColor.rgb;  // black outlines
+          }
+        `,
+      },
+    ];
+
+    const texture = loader.load('resources/data/world/country-outlines-4k.png', render);
+    const material = new THREE.MeshBasicMaterial({map: texture});
+    material.onBeforeCompile = function(shader) {
+      fragmentShaderReplacements.forEach((rep) => {
+        shader.fragmentShader = shader.fragmentShader.replace(rep.from, rep.to);
+      });
+      shader.uniforms.paletteTexture = {value: paletteTexture};
+      shader.uniforms.indexTexture = {value: indexTexture};
+      shader.uniforms.paletteTextureWidth = {value: paletteTextureWidth};
+    };
+    scene.add(new THREE.Mesh(geometry, material));
+  }
+
+  async function loadJSON(url) {
+    const req = await fetch(url);
+    return req.json();
+  }
+
+  let numCountriesSelected = 0;
+  let countryInfos;
+  async function loadCountryData() {
+    countryInfos = await loadJSON('resources/data/world/country-info.json');  /* threejsfundamentals: url */
+
+    const lonFudge = Math.PI * 1.5;
+    const latFudge = Math.PI;
+    // these helpers will make it easy to position the boxes
+    // We can rotate the lon helper on its Y axis to the longitude
+    const lonHelper = new THREE.Object3D();
+    // We rotate the latHelper on its X axis to the latitude
+    const latHelper = new THREE.Object3D();
+    lonHelper.add(latHelper);
+    // The position helper moves the object to the edge of the sphere
+    const positionHelper = new THREE.Object3D();
+    positionHelper.position.z = 1;
+    latHelper.add(positionHelper);
+
+    const labelParentElem = document.querySelector('#labels');
+    for (const countryInfo of countryInfos) {
+      const {lat, lon, min, max, name} = countryInfo;
+
+      // adjust the helpers to point to the latitude and longitude
+      lonHelper.rotation.y = THREE.Math.degToRad(lon) + lonFudge;
+      latHelper.rotation.x = THREE.Math.degToRad(lat) + latFudge;
+
+      // get the position of the lat/lon
+      positionHelper.updateWorldMatrix(true, false);
+      const position = new THREE.Vector3();
+      positionHelper.getWorldPosition(position);
+      countryInfo.position = position;
+
+      // compute the area for each country
+      const width = max[0] - min[0];
+      const height = max[1] - min[1];
+      const area = width * height;
+      countryInfo.area = area;
+
+      // add an element for each country
+      const elem = document.createElement('div');
+      elem.textContent = name;
+      labelParentElem.appendChild(elem);
+      countryInfo.elem = elem;
+    }
+    requestRenderIfNotRequested();
+  }
+  loadCountryData();
+
+  const tempV = new THREE.Vector3();
+  const normalMatrix = new THREE.Matrix3();
+  const positiveZ = new THREE.Vector3(0, 0, 1);
+
+  const settings = {
+    minArea: 20,
+    visibleAngleDeg: 75,
+  };
+  const gui = new dat.GUI({width: 300});
+  gui.add(settings, 'minArea', 0, 50).onChange(requestRenderIfNotRequested);
+  gui.add(settings, 'visibleAngleDeg', 0, 180).onChange(requestRenderIfNotRequested);
+
+  function updateLabels() {
+    // exit if we have not loaded the data yet
+    if (!countryInfos) {
+      return;
+    }
+
+    const large = settings.minArea * settings.minArea;
+    const visibleDot = Math.cos(THREE.Math.degToRad(settings.visibleAngleDeg));
+    // get a matrix that represents a relative orientation of the camera
+    normalMatrix.getNormalMatrix(camera.matrixWorldInverse);
+    for (const countryInfo of countryInfos) {
+      const {position, elem, area, selected} = countryInfo;
+      const largeEnough = area >= large;
+      const show = selected || (numCountriesSelected === 0 && largeEnough);
+      if (!show) {
+        elem.style.display = 'none';
+        continue;
+      }
+
+      // orient the position based on the camera's orientation
+      tempV.copy(position);
+      tempV.applyMatrix3(normalMatrix);
+
+      // get the dot product with positiveZ
+      // -1 = facing directly away and +1 = facing directly toward us
+      const dot = tempV.dot(positiveZ);
+
+      // if the orientation is not facing us hide it.
+      if (dot < visibleDot) {
+        elem.style.display = 'none';
+        continue;
+      }
+
+      // restore the element to its default display style
+      elem.style.display = '';
+
+      // get the normalized screen coordinate of that position
+      // x and y will be in the -1 to +1 range with x = -1 being
+      // on the left and y = -1 being on the bottom
+      tempV.copy(position);
+      tempV.project(camera);
+
+      // convert the normalized position to CSS coordinates
+      const x = (tempV.x *  .5 + .5) * canvas.clientWidth;
+      const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
+
+      // move the elem to that position
+      countryInfo.elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
+    }
+  }
+
+  class GPUPickHelper {
+    constructor() {
+      // create a 1x1 pixel render target
+      this.pickingTexture = new THREE.WebGLRenderTarget(1, 1);
+      this.pixelBuffer = new Uint8Array(4);
+    }
+    pick(cssPosition, scene, camera) {
+      const {pickingTexture, pixelBuffer} = this;
+
+      // set the view offset to represent just a single pixel under the mouse
+      const pixelRatio = renderer.getPixelRatio();
+      camera.setViewOffset(
+          renderer.context.drawingBufferWidth,   // full width
+          renderer.context.drawingBufferHeight,  // full top
+          cssPosition.x * pixelRatio | 0,        // rect x
+          cssPosition.y * pixelRatio | 0,        // rect y
+          1,                                     // rect width
+          1,                                     // rect height
+      );
+      // render the scene
+      renderer.setRenderTarget(pickingTexture);
+      renderer.render(scene, camera);
+      renderer.setRenderTarget(null);
+      // clear the view offset so rendering returns to normal
+      camera.clearViewOffset();
+      //read the pixel
+      renderer.readRenderTargetPixels(
+          pickingTexture,
+          0,   // x
+          0,   // y
+          1,   // width
+          1,   // height
+          pixelBuffer);
+
+      const id =
+          (pixelBuffer[0] <<  0) |
+          (pixelBuffer[1] <<  8) |
+          (pixelBuffer[2] << 16);
+
+      return id;
+    }
+  }
+
+  const pickHelper = new GPUPickHelper();
+
+  function pickCountry(event) {
+    // exit if we have not loaded the data yet
+    if (!countryInfos) {
+      return;
+    }
+
+    const position = {x: event.clientX, y: event.clientY};
+    const id = pickHelper.pick(position, pickingScene, camera);
+    if (id > 0) {
+      const countryInfo = countryInfos[id - 1];
+      const selected = !countryInfo.selected;
+      if (selected && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
+        unselectAllCountries();
+      }
+      numCountriesSelected += selected ? 1 : -1;
+      countryInfo.selected = selected;
+    } else if (numCountriesSelected) {
+      unselectAllCountries();
+    }
+    requestRenderIfNotRequested();
+  }
+
+  function unselectAllCountries() {
+    numCountriesSelected = 0;
+    countryInfos.forEach((countryInfo) => {
+      countryInfo.selected = false;
+    });
+  }
+
+  canvas.addEventListener('mouseup', pickCountry);
+
+  let lastTouch;
+  canvas.addEventListener('touchstart', (event) => {
+    // prevent the window from scrolling
+    event.preventDefault();
+    lastTouch = event.touches[0];
+  }, {passive: false});
+  canvas.addEventListener('touchsmove', (event) => {
+    lastTouch = event.touches[0];
+  });
+  canvas.addEventListener('touchend', () => {
+    pickCountry(lastTouch);
+  });
+
+  function resizeRendererToDisplaySize(renderer) {
+    const canvas = renderer.domElement;
+    const width = canvas.clientWidth;
+    const height = canvas.clientHeight;
+    const needResize = canvas.width !== width || canvas.height !== height;
+    if (needResize) {
+      renderer.setSize(width, height, false);
+    }
+    return needResize;
+  }
+
+  let renderRequested = false;
+
+  function render() {
+    renderRequested = undefined;
+
+    if (resizeRendererToDisplaySize(renderer)) {
+      const canvas = renderer.domElement;
+      camera.aspect = canvas.clientWidth / canvas.clientHeight;
+      camera.updateProjectionMatrix();
+    }
+
+    controls.update();
+
+
+    updateLabels();
+
+    renderer.render(scene, camera);
+  }
+  render();
+
+  function requestRenderIfNotRequested() {
+    if (!renderRequested) {
+      renderRequested = true;
+      requestAnimationFrame(render);
+    }
+  }
+
+  controls.addEventListener('change', requestRenderIfNotRequested);
+  window.addEventListener('resize', requestRenderIfNotRequested);
+}
+
+main();
+</script>
+</html>
+