Browse Source

add lots of objects animated article

Gregg Tavares 6 years ago
parent
commit
59726bae96

+ 704 - 0
threejs/lessons/threejs-optimize-lots-of-objects-animated.md

@@ -0,0 +1,704 @@
+Title: Three.js Optimize Lots of Objects Animated
+Description: Animated merged objects with Morphtargets
+
+This article is a continuation of [an article about optimizing lots of objects
+](threejs-optimize-lots-of-objects.html). If you haven't read that
+yet please read it before proceeding. 
+
+In the previous article we merged around 19000 cubes into a
+single geometry. This had the advantage that it optimized our drawing
+of 19000 cubes but it had the disadvantage of make it harder to
+move any individual cube.
+
+Depending on what we are trying to accomplish there are different solutions.
+In this case let's graph multiple sets of data and animate between the sets.
+
+The first thing we need to do is get multiple sets of data. Ideally we'd
+probably pre-process data offline but in this case let's load 2 sets of
+data and generate 2 more
+
+Here's our old loading code
+
+```js
+loadFile('resources/data/gpw/gpw-v4-basic-demographic-characteristics-rev10_a000_014_2010_1_deg_asc/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc')
+  .then(parseData)
+  .then(addBoxes)
+  .then(render);
+```
+
+Let's change it to something like this
+
+```js
+async function loadData(info) {
+  const text = await loadFile(info.url);
+  info.file = parseData(text);
+}
+
+async function loadAll() {
+  const fileInfos = [
+    {name: 'men',   hueRange: [0.7, 0.3], url: 'resources/data/gpw/gpw-v4-basic-demographic-characteristics-rev10_a000_014_2010_1_deg_asc/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc' },
+    {name: 'women', hueRange: [0.9, 1.1], url: 'resources/data/gpw/gpw-v4-basic-demographic-characteristics-rev10_a000_014_2010_1_deg_asc/gpw_v4_basic_demographic_characteristics_rev10_a000_014ft_2010_cntm_1_deg.asc' },
+  ];
+
+  await Promise.all(fileInfos.map(loadData));
+
+  ...
+}
+loadAll();
+```
+
+The code above will load all the files in `fileInfos` and when done each object
+in `fileInfos` will have a `file` property with the loaded file. `name` and `hueRange`
+we'll use later. `name` will be for a UI field. `hueRange` will be used to
+choose a range of hues to map over.
+
+The two files above are apparently the number of men per area and the number of
+women per area as of 2010. Note, I have no idea if this data is correct but
+it's not important really. The important part is showing different sets
+of data.
+
+Let's generate 2 more sets of data. One being the places where the number
+men are greater than the number of women and visa versa, the places where
+the number of women are greater than the number of men. 
+
+The first thing let's write a function that given a 2 dimensional array of
+of arrays like we had before will map over it to generate a new 2 dimensional
+array of arrays
+
+```js
+function mapValues(data, fn) {
+  return data.map((row, rowNdx) => {
+    return row.map((value, colNdx) => {
+      return fn(value, rowNdx, colNdx);
+    });
+  });
+}
+```
+
+Like the normal `Array.map` function the `mapValues` function calls a function
+`fn` for each value in the array of arrays. It passes it the value and both the
+row and column indices.
+
+Now let's make some code to generate a new file that is a comparison between 2
+files
+
+```js
+function makeDiffFile(baseFile, otherFile, compareFn) {
+  let min;
+  let max;
+  const baseData = baseFile.data;
+  const otherData = otherFile.data;
+  const data = mapValues(baseData, (base, rowNdx, colNdx) => {
+    const other = otherData[rowNdx][colNdx];
+      if (base === undefined || other === undefined) {
+        return undefined;
+      }
+      const value = compareFn(base, other);
+      min = Math.min(min === undefined ? value : min, value);
+      max = Math.max(max === undefined ? value : max, value);
+      return value;
+  });
+  // make a copy of baseFile and replace min, max, and data
+  // with the new data
+  return Object.assign({}, baseFile, {
+    min,
+    max,
+    data,
+  });
+}
+```
+
+The code above uses `mapValues` to generate a new set of data that is
+a comparison based on the `compareFn` function passed in. It also tracks
+the `min` and `max` comparison results. Finally it makes a new file with
+all the same properties as `baseFile` except with a new `min`, `max` and `data`.
+ 
+Then let's use that to make 2 new sets of data
+
+```js
+{
+  const menInfo = fileInfos[0];
+  const womenInfo = fileInfos[1];
+  const menFile = menInfo.file;
+  const womenFile = womenInfo.file;
+
+  function amountGreaterThan(a, b) {
+    return Math.max(a - b, 0);
+  }
+  fileInfos.push({
+    name: '>50%men',
+    hueRange: [0.6, 0.6],
+    file: makeDiffFile(menFile, womenFile, (men, women) => {
+      return amountGreaterThan(men, women);
+    }),
+  });
+  fileInfos.push({
+    name: '>50% women', 
+    hueRange: [0.0, 0.0], 
+    file: makeDiffFile(womenFile, menFile, (women, men) => {
+      return amountGreaterThan(women, men);
+    }),
+  });
+}
+```
+
+Now let's generate a UI to select between these sets of data. First we need
+some UI html
+
+```html
+<body>
+  <canvas id="c"></canvas>
++  <div id="ui"></div>
+</body>
+```
+
+and some CSS to make it appear in the top left area
+
+```css
+#ui {
+  position: absolute;
+  left: 1em;
+  top: 1em;
+}
+#ui>div {
+  font-size: 20pt;
+  padding: 1em;
+  display: inline-block;
+}
+#ui>div.selected {
+  color: red;
+}
+```
+
+Then we can go over each file and generate a set of merged boxes per
+set of data and an element which when hovered over will show that set
+and hide all others.
+
+```js
+// show the selected data, hide the rest
+function showFileInfo(fileInfos, fileInfo) {
+  fileInfos.forEach((info) => {
+    const visible = fileInfo === info;
+    info.root.visible = visible;
+    info.elem.className = visible ? 'selected' : '';
+  });
+  requestRenderIfNotRequested();
+}
+
+const uiElem = document.querySelector('#ui');
+fileInfos.forEach((info) => {
+  const boxes = addBoxes(info.file, info.hueRange);
+  info.root = boxes;
+  const div = document.createElement('div');
+  info.elem = div;
+  div.textContent = info.name;
+  uiElem.appendChild(div);
+  div.addEventListener('mouseover', () => {
+    showFileInfo(fileInfos, info);
+  });
+});
+// show the first set of data
+showFileInfo(fileInfos, fileInfos[0]);
+```
+
+The one more change we need from the previous example is we need to make
+`addBoxes` take a `hueRange`
+
+```js
+-function addBoxes(file) {
++function addBoxes(file, hueRange) {
+
+  ...
+
+    // compute a color
+-    const hue = THREE.Math.lerp(0.7, 0.3, amount);
++    const hue = THREE.Math.lerp(...hueRange, amount);
+
+  ...
+```
+
+and with that we should be able to show 4 sets of data. Hover the mouse over the labels
+or touch them to switch sets
+
+{{{example url="../threejs-lots-of-objects-multiple-data-sets.html" }}}
+
+Note, there are a few strange data points that really stick out. I wonder what's up
+with those!??! In any case how do we animate between these 4 sets of data.
+
+Lots of ideas.
+
+*  Just fade between them using `Material.opacity`
+
+   The problem with this solution is the cubes perfectly overlap which
+   means there will be z-fighting issues. It's possible we could fix
+   that by changing the depth function and using blending. We should
+   probably look into it.
+
+*  Scale up the set we want to see and scale down the other sets
+
+   Because all the boxes have their origin at the center of the planet
+   if we scale them below 1.0 they will sink into the planet. At first that
+   sounds like a good idea but the issue is all the low height boxes
+   will disappear almost immediately and not be replaced until the new
+   data set scales up to 1.0. This makes the transition not very pleasant.
+   We could maybe fix that with a fancy custom shader.
+
+*  Use Morphtargets
+
+   Morphtargets are a way were we supply multiple values for each vertex
+   in the geometry and *morph* or lerp (linear interpolate) between them.
+   Morphtargets are most commonly used for facial animation of 3D characters
+   but that's not their only use.
+
+Let's try morphtargets.
+
+We'll still make a geometry for each set of data but we'll then extract
+the `position` attribute from each one and use them as morphtargets.
+
+First let's change `addBoxes` to just make and return the merged geometry.
+
+```js
+-function addBoxes(file, hueRange) {
++function makeBoxes(file, hueRange) {
+  const {min, max, data} = file;
+  const range = max - min;
+  
+  ...
+
+
+-  const mergedGeometry = THREE.BufferGeometryUtils.mergeBufferGeometries(
+-      geometries, false);
+-  const material = new THREE.MeshBasicMaterial({
+-    vertexColors: THREE.VertexColors,
+-  });
+-  const mesh = new THREE.Mesh(mergedGeometry, material);
+-  scene.add(mesh);
+-  return mesh;
++  return THREE.BufferGeometryUtils.mergeBufferGeometries(
++     geometries, false);
+}
+```
+
+There's one more thing we need to do here though. Morphtargets are required to
+all have exactly the same number of vertices. Vertex #123 in one target needs
+have a corresponding Vertex #123 in all other targets. But, as it is now
+different data sets might have some data points with no data so no box will be
+generated for that point which would mean no corresponding vertices for another
+set. So, we need to check across all data sets and either always generate
+something if there is data in any set or, generate nothing if there is data
+missing in any set. Let's do the latter.
+
+```js
++function dataMissingInAnySet(fileInfos, latNdx, lonNdx) {
++  for (const fileInfo of fileInfos) {
++    if (fileInfo.file.data[latNdx][lonNdx] === undefined) {
++      return true;
++    }
++  }
++  return false;
++}
+
+-function makeBoxes(file, hueRange) {
++function makeBoxes(file, hueRange, fileInfos) {
+  const {min, max, data} = file;
+  const range = max - min;
+
+  ...
+
+  const geometries = [];
+  data.forEach((row, latNdx) => {
+    row.forEach((value, lonNdx) => {
++      if (dataMissingInAnySet(fileInfos, latNdx, lonNdx)) {
++        return;
++      }
+      const amount = (value - min) / range;
+
+  ...
+```
+
+Now we'll change the code that was calling `addBoxes` to use `makeBoxes`
+and setup morphtargets
+
+```js
++// make geometry for each data set
++const geometries = fileInfos.map((info) => {
++  return makeBoxes(info.file, info.hueRange, fileInfos);
++});
++
++// use the first geometry as the base
++// and add all the geometries as morphtargets
++const baseGeometry = geometries[0];
++baseGeometry.morphAttributes.position = geometries.map((geometry, ndx) => {
++  const attribute = geometry.getAttribute('position');
++  const name = `target${ndx}`;
++  attribute.name = name;
++  return attribute;
++});
++const material = new THREE.MeshBasicMaterial({
++  vertexColors: THREE.VertexColors,
++  morphTargets: true,
++});
++const mesh = new THREE.Mesh(baseGeometry, material);
++scene.add(mesh);
+
+const uiElem = document.querySelector('#ui');
+fileInfos.forEach((info) => {
+-  const boxes = addBoxes(info.file, info.hueRange);
+-  info.root = boxes;
+  const div = document.createElement('div');
+  info.elem = div;
+  div.textContent = info.name;
+  uiElem.appendChild(div);
+  function show() {
+    showFileInfo(fileInfos, info);
+  }
+  div.addEventListener('mouseover', show);
+  div.addEventListener('touchstart', show);
+});
+// show the first set of data
+showFileInfo(fileInfos, fileInfos[0]);
+```
+
+Above we make geometry for each data set, use the first one as the base,
+then get a `position` attribute from each geometry and add it as
+a morphtarget to the base geometry for `position`.
+
+Now we need to change how we're showing and hiding the various data sets.
+Instead of showing or hiding a mesh we need to change the influence of the
+morphtargets. For the data set we want to see we need to have an influence of 1
+and for all the ones we don't want to see to we need to have an influence of 0.
+
+We could just set them to 0 or 1 directly but if we did that we wouldn't see any
+animation, it would just snap which would be no different than what we already
+have. We could also write some custom animation code which would be easy but
+because the original webgl globe uses 
+[an animation library](https://github.com/tweenjs/tween.js/) let's use the same one here.
+
+We need to include the library
+
+```html
+<script src="resources/threejs/r98/three.js"></script>
+<script src="resources/threejs/r98/js/utils/BufferGeometryUtils.js"></script>
+<script src="resources/threejs/r98/js/controls/OrbitControls.js"></script>
++<script src="resources/threejs/r98/js/libs/tween.min.js"></script>
+```
+
+And then create a `Tween` to animate the influences.
+
+```js
+// show the selected data, hide the rest
+function showFileInfo(fileInfos, fileInfo) {
+  fileInfos.forEach((info) => {
+    const visible = fileInfo === info;
+-    info.root.visible = visible;
+    info.elem.className = visible ? 'selected' : '';
++    const targets = {};
++    fileInfos.forEach((info, i) => {
++      targets[i] = info === fileInfo ? 1 : 0;
++    });
++    const durationInMs = 1000;
++    new TWEEN.Tween(mesh.morphTargetInfluences)
++      .to(targets, durationInMs)
++      .start();
+  });
+  requestRenderIfNotRequested();
+}
+```
+
+We're also suppose to call `TWEEN.update` every frame inside our render loop
+but that points out a problem. "tween.js" is designed for continuous rendering
+but we are [rendering on demand](threejs-rendering-on-demand.html). We could
+switch to continuous rendering but it's sometimes nice to only render on demand
+as it well stop using the user's power when nothing is happening
+so let's see if we can make it animate on demand.
+
+We'll make a `TweenManager` to help. We'll use it to create the `Tween`s and
+track them. It will have an `update` method that will return `true`
+if we need to call it again and `false` if all the animations are finished.
+
+```js
+class TweenManger {
+  constructor() {
+    this.numTweensRunning = 0;
+  }
+  _handleComplete() {
+    --this.numTweensRunning;
+    console.assert(this.numTweensRunning >= 0);
+  }
+  createTween(targetObject) {
+    const self = this;
+    ++this.numTweensRunning;
+    let userCompleteFn = () => {};
+    // create a new tween and install our own onComplete callback
+    const tween = new TWEEN.Tween(targetObject).onComplete(function(...args) {
+      self._handleComplete();
+      userCompleteFn.call(this, ...args);
+    });
+    // replace the tween's onComplete function with our own
+    // so we can call the user's callback if they supply one.
+    tween.onComplete = (fn) => {
+      userCompleteFn = fn;
+      return tween;
+    };
+    return tween;
+  }
+  update() {
+    TWEEN.update();
+    return this.numTweensRunning > 0;
+  }
+}
+```
+
+To use it we'll create one 
+
+```js
+function main() {
+  const canvas = document.querySelector('#c');
+  const renderer = new THREE.WebGLRenderer({canvas: canvas});
++  const tweenManager = new TweenManger();
+
+  ...
+```
+
+We'll use it to create our `Tween`s.
+
+```js
+// show the selected data, hide the rest
+function showFileInfo(fileInfos, fileInfo) {
+  fileInfos.forEach((info) => {
+    const visible = fileInfo === info;
+    info.elem.className = visible ? 'selected' : '';
+    const targets = {};
+    fileInfos.forEach((info, i) => {
+      targets[i] = info === fileInfo ? 1 : 0;
+    });
+    const durationInMs = 1000;
+-    new TWEEN.Tween(mesh.morphTargetInfluences)
++    tweenManager.createTween(mesh.morphTargetInfluences)
+      .to(targets, durationInMs)
+      .start();
+  });
+  requestRenderIfNotRequested();
+}
+```
+
+Then we'll update our render loop to update the tweens and keep rendering
+if there are still animations running.
+
+```js
+function render() {
+  renderRequested = undefined;
+
+  if (resizeRendererToDisplaySize(renderer)) {
+    const canvas = renderer.domElement;
+    camera.aspect = canvas.clientWidth / canvas.clientHeight;
+    camera.updateProjectionMatrix();
+  }
+
++  if (tweenManager.update()) {
++    requestRenderIfNotRequested();
++  }
+
+  controls.update();
+  renderer.render(scene, camera);
+}
+render();
+```
+
+And with that we should be animating between data sets.
+
+{{{example url="../threejs-lots-of-objects-morphtargets.html" }}}
+
+That seems to work but unfortunately we lost the colors.
+
+Three.js does not support morphtarget colors and in fact this is an issue
+with the original [webgl globe](https://github.com/dataarts/webgl-globe).
+Basically it just makes colors for the first data set. Any other datasets
+use the same colors even if they are vastly different.
+
+Let's see if we can add support for morphing the colors. This might
+be brittle. The least brittle way would probably be to 100% write our own
+shaders but I think it would be useful to see how to modify the built
+in shaders.
+
+The first thing we need to do is make the code extract color a `BufferAttribute` from
+each data set's geometry.
+
+```js
+// use the first geometry as the base
+// and add all the geometries as morphtargets
+const baseGeometry = geometries[0];
+baseGeometry.morphAttributes.position = geometries.map((geometry, ndx) => {
+  const attribute = geometry.getAttribute('position');
+  const name = `target${ndx}`;
+  attribute.name = name;
+  return attribute;
+});
++const colorAttributes = geometries.map((geometry, ndx) => {
++  const attribute = geometry.getAttribute('color');
++  const name = `morphColor${ndx}`;
++  attribute.name = `color${ndx}`;  // just for debugging
++  return {name, attribute};
++});
+const material = new THREE.MeshBasicMaterial({
+  vertexColors: THREE.VertexColors,
+  morphTargets: true,
+});
+```
+
+We then need to modify the three.js shader. Three.js materials have an
+`Material.onBeforeCompile` property we can assign a function. It gives us a
+chance to modify the material's shader before it is passed to WebGL. In fact the
+shader that is provided is actually a special three.js only syntax of shader
+that lists a bunch of shader *chunks* that three.js will substitute with the
+actual GLSL code for each chunk. Here is what the unmodified vertex shader code
+looks like as passed to `onBeforeCompile`.
+
+```glsl
+#include <common>
+#include <uv_pars_vertex>
+#include <uv2_pars_vertex>
+#include <envmap_pars_vertex>
+#include <color_pars_vertex>
+#include <fog_pars_vertex>
+#include <morphtarget_pars_vertex>
+#include <skinning_pars_vertex>
+#include <logdepthbuf_pars_vertex>
+#include <clipping_planes_pars_vertex>
+void main() {
+	#include <uv_vertex>
+	#include <uv2_vertex>
+	#include <color_vertex>
+	#include <skinbase_vertex>
+	#ifdef USE_ENVMAP
+	#include <beginnormal_vertex>
+	#include <morphnormal_vertex>
+	#include <skinnormal_vertex>
+	#include <defaultnormal_vertex>
+	#endif
+	#include <begin_vertex>
+	#include <morphtarget_vertex>
+	#include <skinning_vertex>
+	#include <project_vertex>
+	#include <logdepthbuf_vertex>
+	#include <worldpos_vertex>
+	#include <clipping_planes_vertex>
+	#include <envmap_vertex>
+	#include <fog_vertex>
+}
+```
+
+Digging through the various chunks we want to replace
+the [`morphtarget_pars_vertex` chunk](https://github.com/mrdoob/three.js/blob/dev/src/renderers/shaders/ShaderChunk/morphtarget_pars_vertex.glsl.js)
+the [`morphnormal_vertex` chunk](https://github.com/mrdoob/three.js/blob/dev/src/renderers/shaders/ShaderChunk/morphnormal_vertex.glsl.js)
+the [`morphtarget_vertex` chunk](https://github.com/mrdoob/three.js/blob/dev/src/renderers/shaders/ShaderChunk/morphtarget_vertex.glsl.js)
+the [`color_pars_vertex` chunk](https://github.com/mrdoob/three.js/blob/dev/src/renderers/shaders/ShaderChunk/color_pars_vertex.glsl.js)
+and the [`color_vertex` chunk](https://github.com/mrdoob/three.js/blob/dev/src/renderers/shaders/ShaderChunk/color_vertex.glsl.js)
+
+To do that we'll make a simple array of replacements and apply them in `Material.onBeforeCompile`
+
+```js
+const material = new THREE.MeshBasicMaterial({
+  vertexColors: THREE.VertexColors,
+  morphTargets: true,
+});
++const vertexShaderReplacements = [
++  {
++    from: '#include <morphtarget_pars_vertex>',
++    to: `
++      uniform float morphTargetInfluences[8];
++    `,
++  },
++  {
++    from: '#include <morphnormal_vertex>',
++    to: `
++    `,
++  },
++  {
++    from: '#include <morphtarget_vertex>',
++    to: `
++      transformed += (morphTarget0 - position) * morphTargetInfluences[0];
++      transformed += (morphTarget1 - position) * morphTargetInfluences[1];
++      transformed += (morphTarget2 - position) * morphTargetInfluences[2];
++      transformed += (morphTarget3 - position) * morphTargetInfluences[3];
++    `,
++  },
++  {
++    from: '#include <color_pars_vertex>',
++    to: `
++      varying vec3 vColor;
++      attribute vec3 morphColor0;
++      attribute vec3 morphColor1;
++      attribute vec3 morphColor2;
++      attribute vec3 morphColor3;
++    `,
++  },
++  {
++    from: '#include <color_vertex>',
++    to: `
++      vColor.xyz = morphColor0 * morphTargetInfluences[0] +
++                   morphColor1 * morphTargetInfluences[1] +
++                   morphColor2 * morphTargetInfluences[2] +
++                   morphColor3 * morphTargetInfluences[3];
++    `,
++  },
++];
++material.onBeforeCompile = (shader) => {
++  vertexShaderReplacements.forEach((rep) => {
++    shader.vertexShader = shader.vertexShader.replace(rep.from, rep.to);
++  });
++};
+```
+
+Three.js also sorts morphtargets and applies only the highest influence.
+This lets it allow many more morphtargets as long as only a few are used at
+a time. We need to figure out how it sorted the targets and then set
+our color attributes to match. We can do this by first removing all our
+color attributes and then checking the `morphTarget` attributes and and
+seeing which `BufferAttribute` was assigned. Using the name of the
+`BufferAttribute` we can tell which corresponding color attribute needed.
+
+We do this in `Object3D.onBeforeRender` which is a property of our `Mesh`.
+Three.js will call it just before rendering giving us a chance to fix things
+up.
+
+```js
+const mesh = new THREE.Mesh(baseGeometry, material);
+scene.add(mesh);
++mesh.onBeforeRender = function(renderer, scene, camera, geometry) {
++  // remove all the color attributes
++  for (const {name} of colorAttributes) {
++    geometry.removeAttribute(name);
++  }
++
++  for (let i = 0; i < colorAttributes.length; ++i) {
++    const attrib = geometry.getAttribute(`morphTarget${i}`);
++    if (!attrib) {
++      break;
++    }
++    const ndx = parseInt(/\d+$/.exec(attrib.name)[0]);
++    const name = `morphColor${i}`;
++    geometry.addAttribute(name, colorAttributes[ndx].attribute);
++  }
++};
+```
+
+And with that we should have the colors animating as well as the boxes.
+
+{{{example url="../threejs-lots-of-objects-morphtargets-w-colors.html" }}}
+
+I hope going through this was helpful. Using morphtargets either through the
+services three.js provides or by writing custom shaders is a common technique to
+move lots of objects. As an example we could give every cube a random place in
+another target and morph from that to their first positions on the globe. That
+might be a cool way to introduce the globe.
+
+Note: We could try to just graph percent of men or percent of women or the raw
+difference but based on how we are displaying the info, cubes that grow from the
+surface of the earth, we'd prefer most cubes to be low. If we used one of these
+other comparisons most cubes would be about 1/2 their maximum height which would
+not make a good visualization. Feel free to change the `amountGreaterThan` from
+`Math.max(a - b, 0)` to something like `(a - b)` "raw difference" or `a / (a +
+b)` "percent" and you'll see what I mean.
+
+

+ 2 - 1
threejs/lessons/threejs-optimize-lots-of-objects.md

@@ -527,7 +527,8 @@ faces that are never visible.
 
 The problem with making everything one mesh though is it's no longer easy
 to move any part that was previously separate. Depending on our use case
-though there are creative solutions. We'll explore one in another article.
+though there are creative solutions. We'll explore one in
+[another article](threejs-optimize-lots-of-objects-animated.html).
 
 <canvas id="c"></canvas>
 <script src="../resources/threejs/r98/three.min.js"></script>

+ 1 - 0
threejs/lessons/toc.html

@@ -20,6 +20,7 @@
   <li>Optimization</li>
   <ul>
     <li><a href="/threejs/lessons/threejs-optimize-lots-of-objects.html">Optimizing Lots of Objects</a></li>
+    <li><a href="/threejs/lessons/threejs-optimize-lots-of-objects-animated.html">Optimizing Lots of Objects Animated</a></li>
   </ul>
   <li>Tips</li>
   <ul>

+ 410 - 0
threejs/threejs-lots-of-objects-animated.html

@@ -0,0 +1,410 @@
+<!-- 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 - Lots of Objects - Animated</title>
+    <style>
+    body {
+        margin: 0;
+        color: white;
+    }
+    #c {
+        width: 100vw;
+        height: 100vh;
+        display: block;
+    }
+    #ui {
+      position: absolute;
+      left: 1em;
+      top: 1em;
+    }
+    #ui>div {
+      font-size: 20pt;
+      padding: 1em;
+      display: inline-block;
+    }
+    #ui>div.selected {
+      color: red;
+    }
+    @media (max-width: 700px) {
+      #ui>div {
+        display: block;
+        padding: .25em;
+      }
+    }
+    </style>
+  </head>
+  <body>
+    <canvas id="c"></canvas>
+    <div id="ui"></div>
+  </body>
+<script src="resources/threejs/r98/three.js"></script>
+<script src="resources/threejs/r98/js/utils/BufferGeometryUtils.js"></script>
+<script src="resources/threejs/r98/js/controls/OrbitControls.js"></script>
+<script src="resources/threejs/r98/js/libs/tween.min.js"></script>
+<script>
+'use strict';
+
+/* global THREE, TWEEN */
+
+class TweenManger {
+  constructor() {
+    this.numTweensRunning = 0;
+  }
+  _handleComplete() {
+    --this.numTweensRunning;
+    console.assert(this.numTweensRunning >= 0);  /* eslint no-console: off */
+  }
+  createTween(targetObject) {
+    const self = this;
+    ++this.numTweensRunning;
+    let userCompleteFn = () => {};
+    // create a new tween and install our own onComplete callback
+    const tween = new TWEEN.Tween(targetObject).onComplete(function(...args) {
+      self._handleComplete();
+      userCompleteFn.call(this, ...args);
+    });
+    // replace the tween's onComplete function with our own
+    // so we can call the user's callback if they supply one.
+    tween.onComplete = (fn) => {
+      userCompleteFn = fn;
+      return tween;
+    };
+    return tween;
+  }
+  update() {
+    TWEEN.update();
+    return this.numTweensRunning > 0;
+  }
+}
+
+function main() {
+  const canvas = document.querySelector('#c');
+  const renderer = new THREE.WebGLRenderer({canvas: canvas});
+  const tweenManager = new TweenManger();
+
+  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('black');
+
+  {
+    const loader = new THREE.TextureLoader();
+    const texture = loader.load('resources/images/world.jpg', render);
+    const geometry = new THREE.SphereBufferGeometry(1, 64, 32);
+    const material = new THREE.MeshBasicMaterial({map: texture});
+    scene.add(new THREE.Mesh(geometry, material));
+  }
+
+  async function loadFile(url) {
+    const req = await fetch(url);
+    return req.text();
+  }
+
+  function parseData(text) {
+    const data = [];
+    const settings = {data};
+    let max;
+    let min;
+    // split into lines
+    text.split('\n').forEach((line) => {
+      // split the line by whitespace
+      const parts = line.trim().split(/\s+/);
+      if (parts.length === 2) {
+        // only 2 parts, must be a key/value pair
+        settings[parts[0]] = parseFloat(parts[1]);
+      } else if (parts.length > 2) {
+        // more than 2 parts, must be data
+        const values = parts.map((v) => {
+          const value = parseFloat(v);
+          if (value === settings.NODATA_value) {
+            return undefined;
+          }
+          max = Math.max(max === undefined ? value : max, value);
+          min = Math.min(min === undefined ? value : min, value);
+          return value;
+        });
+        data.push(values);
+      }
+    });
+    return Object.assign(settings, {min, max});
+  }
+
+  function addBoxes(file, hueRange) {
+    const {min, max, data} = file;
+    const range = max - min;
+
+    // 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();
+    scene.add(lonHelper);
+    // 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);
+    // Used to move the center of the cube so it scales from the position Z axis
+    const originHelper = new THREE.Object3D();
+    originHelper.position.z = 0.5;
+    positionHelper.add(originHelper);
+
+    const color = new THREE.Color();
+
+    const lonFudge = Math.PI * .5;
+    const latFudge = Math.PI * -0.135;
+    const geometries = [];
+    data.forEach((row, latNdx) => {
+      row.forEach((value, lonNdx) => {
+        if (value === undefined) {
+          return;
+        }
+        const amount = (value - min) / range;
+
+        const boxWidth = 1;
+        const boxHeight = 1;
+        const boxDepth = 1;
+        const geometry = new THREE.BoxBufferGeometry(boxWidth, boxHeight, boxDepth);
+
+        // adjust the helpers to point to the latitude and longitude
+        lonHelper.rotation.y = THREE.Math.degToRad(lonNdx + file.xllcorner) + lonFudge;
+        latHelper.rotation.x = THREE.Math.degToRad(latNdx + file.yllcorner) + latFudge;
+
+        // use the world matrix of the origin helper to
+        // position this geometry
+        positionHelper.scale.set(0.005, 0.005, THREE.Math.lerp(0.01, 0.5, amount));
+        originHelper.updateWorldMatrix(true, false);
+        geometry.applyMatrix(originHelper.matrixWorld);
+
+        // compute a color
+        const hue = THREE.Math.lerp(...hueRange, amount);
+        const saturation = 1;
+        const lightness = THREE.Math.lerp(0.4, 1.0, amount);
+        color.setHSL(hue, saturation, lightness);
+        // get the colors as an array of values from 0 to 255
+        const rgb = color.toArray().map(v => v * 255);
+
+        // make an array to store colors for each vertex
+        const numVerts = geometry.getAttribute('position').count;
+        const itemSize = 3;  // r, g, b
+        const colors = new Uint8Array(itemSize * numVerts);
+
+        // copy the color into the colors array for each vertex
+        colors.forEach((v, ndx) => {
+          colors[ndx] = rgb[ndx % 3];
+        });
+
+        const normalized = true;
+        const colorAttrib = new THREE.BufferAttribute(colors, itemSize, normalized);
+        geometry.addAttribute('color', colorAttrib);
+
+        geometries.push(geometry);
+      });
+    });
+
+    const mergedGeometry = THREE.BufferGeometryUtils.mergeBufferGeometries(
+        geometries, false);
+    const material = new THREE.MeshBasicMaterial({
+      vertexColors: THREE.VertexColors,
+      transparent: true,
+      opacity: 0,
+    });
+    const mesh = new THREE.Mesh(mergedGeometry, material);
+    scene.add(mesh);
+    return mesh;
+  }
+
+  async function loadData(info) {
+    const text = await loadFile(info.url);
+    info.file = parseData(text);
+  }
+
+  async function loadAll() {
+    const fileInfos = [
+      {name: 'men',   hueRange: [0.7, 0.3], url: 'resources/data/gpw/gpw-v4-basic-demographic-characteristics-rev10_a000_014_2010_1_deg_asc/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc' },
+      {name: 'women', hueRange: [0.9, 1.1], url: 'resources/data/gpw/gpw-v4-basic-demographic-characteristics-rev10_a000_014_2010_1_deg_asc/gpw_v4_basic_demographic_characteristics_rev10_a000_014ft_2010_cntm_1_deg.asc' },
+    ];
+
+    await Promise.all(fileInfos.map(loadData));
+
+    function mapValues(data, fn) {
+      return data.map((row, rowNdx) => {
+        return row.map((value, colNdx) => {
+          return fn(value, rowNdx, colNdx);
+        });
+      });
+    }
+
+    function makeDiffFile(baseFile, otherFile, compareFn) {
+      let min;
+      let max;
+      const baseData = baseFile.data;
+      const otherData = otherFile.data;
+      const data = mapValues(baseData, (base, rowNdx, colNdx) => {
+        const other = otherData[rowNdx][colNdx];
+          if (base === undefined || other === undefined) {
+            return undefined;
+          }
+          const value = compareFn(base, other);
+          min = Math.min(min === undefined ? value : min, value);
+          max = Math.max(max === undefined ? value : max, value);
+          return value;
+      });
+      // make a copy of baseFile and replace min, max, and data
+      // with the new data
+      return Object.assign({}, baseFile, {
+        min,
+        max,
+        data,
+      });
+    }
+
+    // generate a new set of data
+    {
+      const menInfo = fileInfos[0];
+      const womenInfo = fileInfos[1];
+      const menFile = menInfo.file;
+      const womenFile = womenInfo.file;
+
+      function amountGreaterThan(a, b) {
+        return Math.max(a - b, 0);
+      }
+      fileInfos.push({
+        name: '>50%men',
+        hueRange: [0.6, 0.6],
+        file: makeDiffFile(menFile, womenFile, (men, women) => {
+          return amountGreaterThan(men, women);
+        }),
+      });
+      fileInfos.push({
+        name: '>50% women',
+        hueRange: [0.0, 0.0],
+        file: makeDiffFile(womenFile, menFile, (women, men) => {
+          return amountGreaterThan(women, men);
+        }),
+      });
+    }
+
+    function showFileInfo(fileInfos, fileInfo) {
+      fileInfos.forEach((info) => {
+        const durationInMs = 1000;
+        const visible = fileInfo === info;
+//        const scale = visible ? 1 : 0.1;
+        const opacity = visible ? 1 : 0;
+        info.elem.className = visible ? 'selected' : '';
+        info.root.visible = visible || info.root.material.opacity > 0;
+        tweenManager.createTween(info.root.material)
+          .to({opacity}, durationInMs)
+          .start()
+          .onComplete(() => {
+            info.root.visible = visible;
+          });
+//        tweenManager.createTween(info.root.material)
+//          .to({depthWrite: visible}, 0)
+//          .delay(durationInMs * .5)
+//          .start();
+//        tweenManager.createTween(info.root)
+//          .to({visible}, 0)
+//          .delay(durationInMs)
+//          .start();
+//        tweenManager.createTween(info.root.scale)
+//          .to({x: scale, y: scale, z: scale}, durationInMs)
+//          .start();
+      });
+      requestRenderIfNotRequested();
+    }
+
+    const uiElem = document.querySelector('#ui');
+    fileInfos.forEach((info) => {
+      const boxes = addBoxes(info.file, info.hueRange);
+      info.root = boxes;
+//      boxes.scale.set(0.1, 0.1, 0.1);
+      const div = document.createElement('div');
+      info.elem = div;
+      div.textContent = info.name;
+      uiElem.appendChild(div);
+      function show() {
+        showFileInfo(fileInfos, info);
+      }
+      div.addEventListener('mouseover', show);
+      div.addEventListener('touchstart', show);
+    });
+    // show the first set of data
+    showFileInfo(fileInfos, fileInfos[0]);
+  }
+  loadAll();
+
+  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();
+    }
+
+    if (tweenManager.update()) {
+      requestRenderIfNotRequested();
+    }
+
+    controls.update();
+    renderer.render(scene, camera);
+  }
+  render();
+
+  function requestRenderIfNotRequested() {
+    if (!renderRequested) {
+      renderRequested = true;
+      requestAnimationFrame(render);
+    }
+  }
+
+  controls.addEventListener('change', requestRenderIfNotRequested);
+  window.addEventListener('resize', requestRenderIfNotRequested);
+
+  // note: this is a workaround for an OrbitControls issue
+  // in an iframe. Will remove once the issue is fixed in
+  // three.js
+  window.addEventListener('mousedown', (e) => {
+    e.preventDefault();
+    window.focus();
+  });
+  window.addEventListener('keydown', (e) => {
+    e.preventDefault();
+  });
+}
+
+main();
+</script>
+</html>
+

+ 485 - 0
threejs/threejs-lots-of-objects-morphtargets-w-colors.html

@@ -0,0 +1,485 @@
+<!-- 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 - Lots of Objects - MorphTargets w/colors</title>
+    <style>
+    body {
+        margin: 0;
+        color: white;
+    }
+    #c {
+        width: 100vw;
+        height: 100vh;
+        display: block;
+    }
+    #ui {
+      position: absolute;
+      left: 1em;
+      top: 1em;
+    }
+    #ui>div {
+      font-size: 20pt;
+      padding: 1em;
+      display: inline-block;
+    }
+    #ui>div.selected {
+      color: red;
+    }
+    @media (max-width: 700px) {
+      #ui>div {
+        display: block;
+        padding: .25em;
+      }
+    }
+    </style>
+  </head>
+  <body>
+    <canvas id="c"></canvas>
+    <div id="ui"></div>
+  </body>
+<script src="resources/threejs/r98/three.js"></script>
+<script src="resources/threejs/r98/js/utils/BufferGeometryUtils.js"></script>
+<script src="resources/threejs/r98/js/controls/OrbitControls.js"></script>
+<script src="resources/threejs/r98/js/libs/tween.min.js"></script>
+<script>
+'use strict';
+
+/* global THREE, TWEEN */
+
+class TweenManger {
+  constructor() {
+    this.numTweensRunning = 0;
+  }
+  _handleComplete() {
+    --this.numTweensRunning;
+    console.assert(this.numTweensRunning >= 0);  /* eslint no-console: off */
+  }
+  createTween(targetObject) {
+    const self = this;
+    ++this.numTweensRunning;
+    let userCompleteFn = () => {};
+    // create a new tween and install our own onComplete callback
+    const tween = new TWEEN.Tween(targetObject).onComplete(function(...args) {
+      self._handleComplete();
+      userCompleteFn.call(this, ...args);
+    });
+    // replace the tween's onComplete function with our own
+    // so we can call the user's callback if they supply one.
+    tween.onComplete = (fn) => {
+      userCompleteFn = fn;
+      return tween;
+    };
+    return tween;
+  }
+  update() {
+    TWEEN.update();
+    return this.numTweensRunning > 0;
+  }
+}
+
+function main() {
+  const canvas = document.querySelector('#c');
+  const renderer = new THREE.WebGLRenderer({canvas: canvas});
+  const tweenManager = new TweenManger();
+
+  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('black');
+
+  {
+    const loader = new THREE.TextureLoader();
+    const texture = loader.load('resources/images/world.jpg', render);
+    const geometry = new THREE.SphereBufferGeometry(1, 64, 32);
+    const material = new THREE.MeshBasicMaterial({map: texture});
+    scene.add(new THREE.Mesh(geometry, material));
+  }
+
+  async function loadFile(url) {
+    const req = await fetch(url);
+    return req.text();
+  }
+
+  function parseData(text) {
+    const data = [];
+    const settings = {data};
+    let max;
+    let min;
+    // split into lines
+    text.split('\n').forEach((line) => {
+      // split the line by whitespace
+      const parts = line.trim().split(/\s+/);
+      if (parts.length === 2) {
+        // only 2 parts, must be a key/value pair
+        settings[parts[0]] = parseFloat(parts[1]);
+      } else if (parts.length > 2) {
+        // more than 2 parts, must be data
+        const values = parts.map((v) => {
+          const value = parseFloat(v);
+          if (value === settings.NODATA_value) {
+            return undefined;
+          }
+          max = Math.max(max === undefined ? value : max, value);
+          min = Math.min(min === undefined ? value : min, value);
+          return value;
+        });
+        data.push(values);
+      }
+    });
+    return Object.assign(settings, {min, max});
+  }
+
+  function dataMissingInAnySet(fileInfos, latNdx, lonNdx) {
+    for (const fileInfo of fileInfos) {
+      if (fileInfo.file.data[latNdx][lonNdx] === undefined) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  function makeBoxes(file, hueRange, fileInfos) {
+    const {min, max, data} = file;
+    const range = max - min;
+
+    // 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();
+    scene.add(lonHelper);
+    // 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);
+    // Used to move the center of the cube so it scales from the position Z axis
+    const originHelper = new THREE.Object3D();
+    originHelper.position.z = 0.5;
+    positionHelper.add(originHelper);
+
+    const color = new THREE.Color();
+
+    const lonFudge = Math.PI * .5;
+    const latFudge = Math.PI * -0.135;
+    const geometries = [];
+    data.forEach((row, latNdx) => {
+      row.forEach((value, lonNdx) => {
+        if (dataMissingInAnySet(fileInfos, latNdx, lonNdx)) {
+          return;
+        }
+        const amount = (value - min) / range;
+
+        const boxWidth = 1;
+        const boxHeight = 1;
+        const boxDepth = 1;
+        const geometry = new THREE.BoxBufferGeometry(boxWidth, boxHeight, boxDepth);
+
+        // adjust the helpers to point to the latitude and longitude
+        lonHelper.rotation.y = THREE.Math.degToRad(lonNdx + file.xllcorner) + lonFudge;
+        latHelper.rotation.x = THREE.Math.degToRad(latNdx + file.yllcorner) + latFudge;
+
+        // use the world matrix of the origin helper to
+        // position this geometry
+        positionHelper.scale.set(0.005, 0.005, THREE.Math.lerp(0.01, 0.5, amount));
+        originHelper.updateWorldMatrix(true, false);
+        geometry.applyMatrix(originHelper.matrixWorld);
+
+        // compute a color
+        const hue = THREE.Math.lerp(...hueRange, amount);
+        const saturation = 1;
+        const lightness = THREE.Math.lerp(0.4, 1.0, amount);
+        color.setHSL(hue, saturation, lightness);
+        // get the colors as an array of values from 0 to 255
+        const rgb = color.toArray().map(v => v * 255);
+
+        // make an array to store colors for each vertex
+        const numVerts = geometry.getAttribute('position').count;
+        const itemSize = 3;  // r, g, b
+        const colors = new Uint8Array(itemSize * numVerts);
+
+        // copy the color into the colors array for each vertex
+        colors.forEach((v, ndx) => {
+          colors[ndx] = rgb[ndx % 3];
+        });
+
+        const normalized = true;
+        const colorAttrib = new THREE.BufferAttribute(colors, itemSize, normalized);
+        geometry.addAttribute('color', colorAttrib);
+
+        geometries.push(geometry);
+      });
+    });
+
+    return THREE.BufferGeometryUtils.mergeBufferGeometries(
+       geometries, false);
+  }
+
+  async function loadData(info) {
+    const text = await loadFile(info.url);
+    info.file = parseData(text);
+  }
+
+  async function loadAll() {
+    const fileInfos = [
+      {name: 'men',   hueRange: [0.7, 0.3], url: 'resources/data/gpw/gpw-v4-basic-demographic-characteristics-rev10_a000_014_2010_1_deg_asc/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc' },
+      {name: 'women', hueRange: [0.9, 1.1], url: 'resources/data/gpw/gpw-v4-basic-demographic-characteristics-rev10_a000_014_2010_1_deg_asc/gpw_v4_basic_demographic_characteristics_rev10_a000_014ft_2010_cntm_1_deg.asc' },
+    ];
+
+    await Promise.all(fileInfos.map(loadData));
+
+    function mapValues(data, fn) {
+      return data.map((row, rowNdx) => {
+        return row.map((value, colNdx) => {
+          return fn(value, rowNdx, colNdx);
+        });
+      });
+    }
+
+    function makeDiffFile(baseFile, otherFile, compareFn) {
+      let min;
+      let max;
+      const baseData = baseFile.data;
+      const otherData = otherFile.data;
+      const data = mapValues(baseData, (base, rowNdx, colNdx) => {
+        const other = otherData[rowNdx][colNdx];
+          if (base === undefined || other === undefined) {
+            return undefined;
+          }
+          const value = compareFn(base, other);
+          min = Math.min(min === undefined ? value : min, value);
+          max = Math.max(max === undefined ? value : max, value);
+          return value;
+      });
+      // make a copy of baseFile and replace min, max, and data
+      // with the new data
+      return Object.assign({}, baseFile, {
+        min,
+        max,
+        data,
+      });
+    }
+
+    // generate a new set of data
+    {
+      const menInfo = fileInfos[0];
+      const womenInfo = fileInfos[1];
+      const menFile = menInfo.file;
+      const womenFile = womenInfo.file;
+
+      function amountGreaterThan(a, b) {
+        return Math.max(a - b, 0);
+      }
+      fileInfos.push({
+        name: '>50%men',
+        hueRange: [0.6, 0.6],
+        file: makeDiffFile(menFile, womenFile, (men, women) => {
+          return amountGreaterThan(men, women);
+        }),
+      });
+      fileInfos.push({
+        name: '>50% women',
+        hueRange: [0.0, 0.0],
+        file: makeDiffFile(womenFile, menFile, (women, men) => {
+          return amountGreaterThan(women, men);
+        }),
+      });
+    }
+
+    // make geometry for each data set
+    const geometries = fileInfos.map((info) => {
+      return makeBoxes(info.file, info.hueRange, fileInfos);
+    });
+
+    // use the first geometry as the base
+    // and add all the geometries as morphtargets
+    const baseGeometry = geometries[0];
+    baseGeometry.morphAttributes.position = geometries.map((geometry, ndx) => {
+      const attribute = geometry.getAttribute('position');
+      const name = `target${ndx}`;
+      attribute.name = name;
+      return attribute;
+    });
+    const colorAttributes = geometries.map((geometry, ndx) => {
+      const attribute = geometry.getAttribute('color');
+      const name = `morphColor${ndx}`;
+      attribute.name = `color${ndx}`;  // just for debugging
+      return {name, attribute};
+    });
+    const material = new THREE.MeshBasicMaterial({
+      vertexColors: THREE.VertexColors,
+      morphTargets: true,
+    });
+    const vertexShaderReplacements = [
+      {
+        from: '#include <morphtarget_pars_vertex>',
+        to: `
+          uniform float morphTargetInfluences[8];
+        `,
+      },
+      {
+        from: '#include <morphnormal_vertex>',
+        to: `
+        `,
+      },
+      {
+        from: '#include <morphtarget_vertex>',
+        to: `
+          transformed += (morphTarget0 - position) * morphTargetInfluences[0];
+          transformed += (morphTarget1 - position) * morphTargetInfluences[1];
+          transformed += (morphTarget2 - position) * morphTargetInfluences[2];
+          transformed += (morphTarget3 - position) * morphTargetInfluences[3];
+        `,
+      },
+      {
+        from: '#include <color_pars_vertex>',
+        to: `
+          varying vec3 vColor;
+          attribute vec3 morphColor0;
+          attribute vec3 morphColor1;
+          attribute vec3 morphColor2;
+          attribute vec3 morphColor3;
+        `,
+      },
+      {
+        from: '#include <color_vertex>',
+        to: `
+          vColor.xyz = morphColor0 * morphTargetInfluences[0] +
+                       morphColor1 * morphTargetInfluences[1] +
+                       morphColor2 * morphTargetInfluences[2] +
+                       morphColor3 * morphTargetInfluences[3];
+        `,
+      },
+    ];
+    material.onBeforeCompile = (shader) => {
+      vertexShaderReplacements.forEach((rep) => {
+        shader.vertexShader = shader.vertexShader.replace(rep.from, rep.to);
+      });
+    };
+    const mesh = new THREE.Mesh(baseGeometry, material);
+    scene.add(mesh);
+    mesh.onBeforeRender = function(renderer, scene, camera, geometry) {
+      // remove all the color attributes
+      for (const {name} of colorAttributes) {
+        geometry.removeAttribute(name);
+      }
+
+      for (let i = 0; i < colorAttributes.length; ++i) {
+        const attrib = geometry.getAttribute(`morphTarget${i}`);
+        if (!attrib) {
+          break;
+        }
+        const ndx = parseInt(/\d+$/.exec(attrib.name)[0]);
+        const name = `morphColor${i}`;
+        geometry.addAttribute(name, colorAttributes[ndx].attribute);
+      }
+    };
+
+    // show the selected data, hide the rest
+    function showFileInfo(fileInfos, fileInfo) {
+      fileInfos.forEach((info) => {
+        const visible = fileInfo === info;
+        info.elem.className = visible ? 'selected' : '';
+        const targets = {};
+        fileInfos.forEach((info, i) => {
+          targets[i] = info === fileInfo ? 1 : 0;
+        });
+        const durationInMs = 1000;
+        tweenManager.createTween(mesh.morphTargetInfluences)
+          .to(targets, durationInMs)
+          .start();
+      });
+      requestRenderIfNotRequested();
+    }
+
+    const uiElem = document.querySelector('#ui');
+    fileInfos.forEach((info) => {
+      const div = document.createElement('div');
+      info.elem = div;
+      div.textContent = info.name;
+      uiElem.appendChild(div);
+      function show() {
+        showFileInfo(fileInfos, info);
+      }
+      div.addEventListener('mouseover', show);
+      div.addEventListener('touchstart', show);
+    });
+    // show the first set of data
+    showFileInfo(fileInfos, fileInfos[0]);
+  }
+  loadAll();
+
+  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();
+    }
+
+    if (tweenManager.update()) {
+      requestRenderIfNotRequested();
+    }
+
+    controls.update();
+    renderer.render(scene, camera);
+  }
+  render();
+
+  function requestRenderIfNotRequested() {
+    if (!renderRequested) {
+      renderRequested = true;
+      requestAnimationFrame(render);
+    }
+  }
+
+  controls.addEventListener('change', requestRenderIfNotRequested);
+  window.addEventListener('resize', requestRenderIfNotRequested);
+
+  // note: this is a workaround for an OrbitControls issue
+  // in an iframe. Will remove once the issue is fixed in
+  // three.js
+  window.addEventListener('mousedown', (e) => {
+    e.preventDefault();
+    window.focus();
+  });
+  window.addEventListener('keydown', (e) => {
+    e.preventDefault();
+  });
+}
+
+main();
+</script>
+</html>
+

+ 417 - 0
threejs/threejs-lots-of-objects-morphtargets.html

@@ -0,0 +1,417 @@
+<!-- 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 - Lots of Objects - Morphtargets</title>
+    <style>
+    body {
+        margin: 0;
+        color: white;
+    }
+    #c {
+        width: 100vw;
+        height: 100vh;
+        display: block;
+    }
+    #ui {
+      position: absolute;
+      left: 1em;
+      top: 1em;
+    }
+    #ui>div {
+      font-size: 20pt;
+      padding: 1em;
+      display: inline-block;
+    }
+    #ui>div.selected {
+      color: red;
+    }
+    @media (max-width: 700px) {
+      #ui>div {
+        display: block;
+        padding: .25em;
+      }
+    }
+    </style>
+  </head>
+  <body>
+    <canvas id="c"></canvas>
+    <div id="ui"></div>
+  </body>
+<script src="resources/threejs/r98/three.js"></script>
+<script src="resources/threejs/r98/js/utils/BufferGeometryUtils.js"></script>
+<script src="resources/threejs/r98/js/controls/OrbitControls.js"></script>
+<script src="resources/threejs/r98/js/libs/tween.min.js"></script>
+<script>
+'use strict';
+
+/* global THREE, TWEEN */
+
+class TweenManger {
+  constructor() {
+    this.numTweensRunning = 0;
+  }
+  _handleComplete() {
+    --this.numTweensRunning;
+    console.assert(this.numTweensRunning >= 0);  /* eslint no-console: off */
+  }
+  createTween(targetObject) {
+    const self = this;
+    ++this.numTweensRunning;
+    let userCompleteFn = () => {};
+    // create a new tween and install our own onComplete callback
+    const tween = new TWEEN.Tween(targetObject).onComplete(function(...args) {
+      self._handleComplete();
+      userCompleteFn.call(this, ...args);
+    });
+    // replace the tween's onComplete function with our own
+    // so we can call the user's callback if they supply one.
+    tween.onComplete = (fn) => {
+      userCompleteFn = fn;
+      return tween;
+    };
+    return tween;
+  }
+  update() {
+    TWEEN.update();
+    return this.numTweensRunning > 0;
+  }
+}
+
+function main() {
+  const canvas = document.querySelector('#c');
+  const renderer = new THREE.WebGLRenderer({canvas: canvas});
+  const tweenManager = new TweenManger();
+
+  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('black');
+
+  {
+    const loader = new THREE.TextureLoader();
+    const texture = loader.load('resources/images/world.jpg', render);
+    const geometry = new THREE.SphereBufferGeometry(1, 64, 32);
+    const material = new THREE.MeshBasicMaterial({map: texture});
+    scene.add(new THREE.Mesh(geometry, material));
+  }
+
+  async function loadFile(url) {
+    const req = await fetch(url);
+    return req.text();
+  }
+
+  function parseData(text) {
+    const data = [];
+    const settings = {data};
+    let max;
+    let min;
+    // split into lines
+    text.split('\n').forEach((line) => {
+      // split the line by whitespace
+      const parts = line.trim().split(/\s+/);
+      if (parts.length === 2) {
+        // only 2 parts, must be a key/value pair
+        settings[parts[0]] = parseFloat(parts[1]);
+      } else if (parts.length > 2) {
+        // more than 2 parts, must be data
+        const values = parts.map((v) => {
+          const value = parseFloat(v);
+          if (value === settings.NODATA_value) {
+            return undefined;
+          }
+          max = Math.max(max === undefined ? value : max, value);
+          min = Math.min(min === undefined ? value : min, value);
+          return value;
+        });
+        data.push(values);
+      }
+    });
+    return Object.assign(settings, {min, max});
+  }
+
+  function dataMissingInAnySet(fileInfos, latNdx, lonNdx) {
+    for (const fileInfo of fileInfos) {
+      if (fileInfo.file.data[latNdx][lonNdx] === undefined) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  function makeBoxes(file, hueRange, fileInfos) {
+    const {min, max, data} = file;
+    const range = max - min;
+
+    // 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();
+    scene.add(lonHelper);
+    // 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);
+    // Used to move the center of the cube so it scales from the position Z axis
+    const originHelper = new THREE.Object3D();
+    originHelper.position.z = 0.5;
+    positionHelper.add(originHelper);
+
+    const color = new THREE.Color();
+
+    const lonFudge = Math.PI * .5;
+    const latFudge = Math.PI * -0.135;
+    const geometries = [];
+    data.forEach((row, latNdx) => {
+      row.forEach((value, lonNdx) => {
+        if (dataMissingInAnySet(fileInfos, latNdx, lonNdx)) {
+          return;
+        }
+        const amount = (value - min) / range;
+
+        const boxWidth = 1;
+        const boxHeight = 1;
+        const boxDepth = 1;
+        const geometry = new THREE.BoxBufferGeometry(boxWidth, boxHeight, boxDepth);
+
+        // adjust the helpers to point to the latitude and longitude
+        lonHelper.rotation.y = THREE.Math.degToRad(lonNdx + file.xllcorner) + lonFudge;
+        latHelper.rotation.x = THREE.Math.degToRad(latNdx + file.yllcorner) + latFudge;
+
+        // use the world matrix of the origin helper to
+        // position this geometry
+        positionHelper.scale.set(0.005, 0.005, THREE.Math.lerp(0.01, 0.5, amount));
+        originHelper.updateWorldMatrix(true, false);
+        geometry.applyMatrix(originHelper.matrixWorld);
+
+        // compute a color
+        const hue = THREE.Math.lerp(...hueRange, amount);
+        const saturation = 1;
+        const lightness = THREE.Math.lerp(0.4, 1.0, amount);
+        color.setHSL(hue, saturation, lightness);
+        // get the colors as an array of values from 0 to 255
+        const rgb = color.toArray().map(v => v * 255);
+
+        // make an array to store colors for each vertex
+        const numVerts = geometry.getAttribute('position').count;
+        const itemSize = 3;  // r, g, b
+        const colors = new Uint8Array(itemSize * numVerts);
+
+        // copy the color into the colors array for each vertex
+        colors.forEach((v, ndx) => {
+          colors[ndx] = rgb[ndx % 3];
+        });
+
+        const normalized = true;
+        const colorAttrib = new THREE.BufferAttribute(colors, itemSize, normalized);
+        geometry.addAttribute('color', colorAttrib);
+
+        geometries.push(geometry);
+      });
+    });
+
+    return THREE.BufferGeometryUtils.mergeBufferGeometries(
+       geometries, false);
+  }
+
+  async function loadData(info) {
+    const text = await loadFile(info.url);
+    info.file = parseData(text);
+  }
+
+  async function loadAll() {
+    const fileInfos = [
+      {name: 'men',   hueRange: [0.7, 0.3], url: 'resources/data/gpw/gpw-v4-basic-demographic-characteristics-rev10_a000_014_2010_1_deg_asc/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc' },
+      {name: 'women', hueRange: [0.9, 1.1], url: 'resources/data/gpw/gpw-v4-basic-demographic-characteristics-rev10_a000_014_2010_1_deg_asc/gpw_v4_basic_demographic_characteristics_rev10_a000_014ft_2010_cntm_1_deg.asc' },
+    ];
+
+    await Promise.all(fileInfos.map(loadData));
+
+    function mapValues(data, fn) {
+      return data.map((row, rowNdx) => {
+        return row.map((value, colNdx) => {
+          return fn(value, rowNdx, colNdx);
+        });
+      });
+    }
+
+    function makeDiffFile(baseFile, otherFile, compareFn) {
+      let min;
+      let max;
+      const baseData = baseFile.data;
+      const otherData = otherFile.data;
+      const data = mapValues(baseData, (base, rowNdx, colNdx) => {
+        const other = otherData[rowNdx][colNdx];
+          if (base === undefined || other === undefined) {
+            return undefined;
+          }
+          const value = compareFn(base, other);
+          min = Math.min(min === undefined ? value : min, value);
+          max = Math.max(max === undefined ? value : max, value);
+          return value;
+      });
+      // make a copy of baseFile and replace min, max, and data
+      // with the new data
+      return Object.assign({}, baseFile, {
+        min,
+        max,
+        data,
+      });
+    }
+
+    // generate a new set of data
+    {
+      const menInfo = fileInfos[0];
+      const womenInfo = fileInfos[1];
+      const menFile = menInfo.file;
+      const womenFile = womenInfo.file;
+
+      function amountGreaterThan(a, b) {
+        return Math.max(a - b, 0);
+      }
+      fileInfos.push({
+        name: '>50%men',
+        hueRange: [0.6, 0.6],
+        file: makeDiffFile(menFile, womenFile, (men, women) => {
+          return amountGreaterThan(men, women);
+        }),
+      });
+      fileInfos.push({
+        name: '>50% women',
+        hueRange: [0.0, 0.0],
+        file: makeDiffFile(womenFile, menFile, (women, men) => {
+          return amountGreaterThan(women, men);
+        }),
+      });
+    }
+
+    // make geometry for each data set
+    const geometries = fileInfos.map((info) => {
+      return makeBoxes(info.file, info.hueRange, fileInfos);
+    });
+
+    // use the first geometry as the base
+    // and add all the geometries as morphtargets
+    const baseGeometry = geometries[0];
+    baseGeometry.morphAttributes.position = geometries.map((geometry, ndx) => {
+      const attribute = geometry.getAttribute('position');
+      const name = `target${ndx}`;
+      attribute.name = name;
+      return attribute;
+    });
+    const material = new THREE.MeshBasicMaterial({
+      vertexColors: THREE.VertexColors,
+      morphTargets: true,
+    });
+    const mesh = new THREE.Mesh(baseGeometry, material);
+    scene.add(mesh);
+
+    // show the selected data, hide the rest
+    function showFileInfo(fileInfos, fileInfo) {
+      fileInfos.forEach((info) => {
+        const visible = fileInfo === info;
+        info.elem.className = visible ? 'selected' : '';
+        const targets = {};
+        fileInfos.forEach((info, i) => {
+          targets[i] = info === fileInfo ? 1 : 0;
+        });
+        const durationInMs = 1000;
+        tweenManager.createTween(mesh.morphTargetInfluences)
+          .to(targets, durationInMs)
+          .start();
+      });
+      requestRenderIfNotRequested();
+    }
+
+    const uiElem = document.querySelector('#ui');
+    fileInfos.forEach((info) => {
+      const div = document.createElement('div');
+      info.elem = div;
+      div.textContent = info.name;
+      uiElem.appendChild(div);
+      function show() {
+        showFileInfo(fileInfos, info);
+      }
+      div.addEventListener('mouseover', show);
+      div.addEventListener('touchstart', show);
+    });
+    // show the first set of data
+    showFileInfo(fileInfos, fileInfos[0]);
+  }
+  loadAll();
+
+  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();
+    }
+
+    if (tweenManager.update()) {
+      requestRenderIfNotRequested();
+    }
+
+    controls.update();
+    renderer.render(scene, camera);
+  }
+  render();
+
+  function requestRenderIfNotRequested() {
+    if (!renderRequested) {
+      renderRequested = true;
+      requestAnimationFrame(render);
+    }
+  }
+
+  controls.addEventListener('change', requestRenderIfNotRequested);
+  window.addEventListener('resize', requestRenderIfNotRequested);
+
+  // note: this is a workaround for an OrbitControls issue
+  // in an iframe. Will remove once the issue is fixed in
+  // three.js
+  window.addEventListener('mousedown', (e) => {
+    e.preventDefault();
+    window.focus();
+  });
+  window.addEventListener('keydown', (e) => {
+    e.preventDefault();
+  });
+}
+
+main();
+</script>
+</html>
+

+ 351 - 0
threejs/threejs-lots-of-objects-multiple-data-sets.html

@@ -0,0 +1,351 @@
+<!-- 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 - Lots of Objects - Multiple Datasets</title>
+    <style>
+    body {
+        margin: 0;
+        color: white;
+    }
+    #c {
+        width: 100vw;
+        height: 100vh;
+        display: block;
+    }
+    #ui {
+      position: absolute;
+      left: 1em;
+      top: 1em;
+    }
+    #ui>div {
+      font-size: 20pt;
+      padding: 1em;
+      display: inline-block;
+    }
+    #ui>div.selected {
+      color: red;
+    }
+    @media (max-width: 700px) {
+      #ui>div {
+        display: block;
+        padding: .25em;
+      }
+    }
+    </style>
+  </head>
+  <body>
+    <canvas id="c"></canvas>
+    <div id="ui"></div>
+  </body>
+<script src="resources/threejs/r98/three.js"></script>
+<script src="resources/threejs/r98/js/utils/BufferGeometryUtils.js"></script>
+<script src="resources/threejs/r98/js/controls/OrbitControls.js"></script>
+<script>
+'use strict';
+
+/* global THREE */
+
+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('black');
+
+  {
+    const loader = new THREE.TextureLoader();
+    const texture = loader.load('resources/images/world.jpg', render);
+    const geometry = new THREE.SphereBufferGeometry(1, 64, 32);
+    const material = new THREE.MeshBasicMaterial({map: texture});
+    scene.add(new THREE.Mesh(geometry, material));
+  }
+
+  async function loadFile(url) {
+    const req = await fetch(url);
+    return req.text();
+  }
+
+  function parseData(text) {
+    const data = [];
+    const settings = {data};
+    let max;
+    let min;
+    // split into lines
+    text.split('\n').forEach((line) => {
+      // split the line by whitespace
+      const parts = line.trim().split(/\s+/);
+      if (parts.length === 2) {
+        // only 2 parts, must be a key/value pair
+        settings[parts[0]] = parseFloat(parts[1]);
+      } else if (parts.length > 2) {
+        // more than 2 parts, must be data
+        const values = parts.map((v) => {
+          const value = parseFloat(v);
+          if (value === settings.NODATA_value) {
+            return undefined;
+          }
+          max = Math.max(max === undefined ? value : max, value);
+          min = Math.min(min === undefined ? value : min, value);
+          return value;
+        });
+        data.push(values);
+      }
+    });
+    return Object.assign(settings, {min, max});
+  }
+
+  function addBoxes(file, hueRange) {
+    const {min, max, data} = file;
+    const range = max - min;
+
+    // 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();
+    scene.add(lonHelper);
+    // 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);
+    // Used to move the center of the cube so it scales from the position Z axis
+    const originHelper = new THREE.Object3D();
+    originHelper.position.z = 0.5;
+    positionHelper.add(originHelper);
+
+    const color = new THREE.Color();
+
+    const lonFudge = Math.PI * .5;
+    const latFudge = Math.PI * -0.135;
+    const geometries = [];
+    data.forEach((row, latNdx) => {
+      row.forEach((value, lonNdx) => {
+        if (value === undefined) {
+          return;
+        }
+        const amount = (value - min) / range;
+
+        const boxWidth = 1;
+        const boxHeight = 1;
+        const boxDepth = 1;
+        const geometry = new THREE.BoxBufferGeometry(boxWidth, boxHeight, boxDepth);
+
+        // adjust the helpers to point to the latitude and longitude
+        lonHelper.rotation.y = THREE.Math.degToRad(lonNdx + file.xllcorner) + lonFudge;
+        latHelper.rotation.x = THREE.Math.degToRad(latNdx + file.yllcorner) + latFudge;
+
+        // use the world matrix of the origin helper to
+        // position this geometry
+        positionHelper.scale.set(0.005, 0.005, THREE.Math.lerp(0.01, 0.5, amount));
+        originHelper.updateWorldMatrix(true, false);
+        geometry.applyMatrix(originHelper.matrixWorld);
+
+        // compute a color
+        const hue = THREE.Math.lerp(...hueRange, amount);
+        const saturation = 1;
+        const lightness = THREE.Math.lerp(0.4, 1.0, amount);
+        color.setHSL(hue, saturation, lightness);
+        // get the colors as an array of values from 0 to 255
+        const rgb = color.toArray().map(v => v * 255);
+
+        // make an array to store colors for each vertex
+        const numVerts = geometry.getAttribute('position').count;
+        const itemSize = 3;  // r, g, b
+        const colors = new Uint8Array(itemSize * numVerts);
+
+        // copy the color into the colors array for each vertex
+        colors.forEach((v, ndx) => {
+          colors[ndx] = rgb[ndx % 3];
+        });
+
+        const normalized = true;
+        const colorAttrib = new THREE.BufferAttribute(colors, itemSize, normalized);
+        geometry.addAttribute('color', colorAttrib);
+
+        geometries.push(geometry);
+      });
+    });
+
+    const mergedGeometry = THREE.BufferGeometryUtils.mergeBufferGeometries(
+        geometries, false);
+    const material = new THREE.MeshBasicMaterial({
+      vertexColors: THREE.VertexColors,
+    });
+    const mesh = new THREE.Mesh(mergedGeometry, material);
+    scene.add(mesh);
+    return mesh;
+  }
+
+  async function loadData(info) {
+    const text = await loadFile(info.url);
+    info.file = parseData(text);
+  }
+
+  async function loadAll() {
+    const fileInfos = [
+      {name: 'men',   hueRange: [0.7, 0.3], url: 'resources/data/gpw/gpw-v4-basic-demographic-characteristics-rev10_a000_014_2010_1_deg_asc/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc' },
+      {name: 'women', hueRange: [0.9, 1.1], url: 'resources/data/gpw/gpw-v4-basic-demographic-characteristics-rev10_a000_014_2010_1_deg_asc/gpw_v4_basic_demographic_characteristics_rev10_a000_014ft_2010_cntm_1_deg.asc' },
+    ];
+
+    await Promise.all(fileInfos.map(loadData));
+
+    function mapValues(data, fn) {
+      return data.map((row, rowNdx) => {
+        return row.map((value, colNdx) => {
+          return fn(value, rowNdx, colNdx);
+        });
+      });
+    }
+
+    function makeDiffFile(baseFile, otherFile, compareFn) {
+      let min;
+      let max;
+      const baseData = baseFile.data;
+      const otherData = otherFile.data;
+      const data = mapValues(baseData, (base, rowNdx, colNdx) => {
+        const other = otherData[rowNdx][colNdx];
+          if (base === undefined || other === undefined) {
+            return undefined;
+          }
+          const value = compareFn(base, other);
+          min = Math.min(min === undefined ? value : min, value);
+          max = Math.max(max === undefined ? value : max, value);
+          return value;
+      });
+      // make a copy of baseFile and replace min, max, and data
+      // with the new data
+      return Object.assign({}, baseFile, {
+        min,
+        max,
+        data,
+      });
+    }
+
+    // generate a new set of data
+    {
+      const menInfo = fileInfos[0];
+      const womenInfo = fileInfos[1];
+      const menFile = menInfo.file;
+      const womenFile = womenInfo.file;
+
+      function amountGreaterThan(a, b) {
+        return Math.max(a - b, 0);
+      }
+      fileInfos.push({
+        name: '>50%men',
+        hueRange: [0.6, 0.6],
+        file: makeDiffFile(menFile, womenFile, (men, women) => {
+          return amountGreaterThan(men, women);
+        }),
+      });
+      fileInfos.push({
+        name: '>50% women',
+        hueRange: [0.0, 0.0],
+        file: makeDiffFile(womenFile, menFile, (women, men) => {
+          return amountGreaterThan(women, men);
+        }),
+      });
+    }
+
+    // show the selected data, hide the rest
+    function showFileInfo(fileInfos, fileInfo) {
+      fileInfos.forEach((info) => {
+        const visible = fileInfo === info;
+        info.root.visible = visible;
+        info.elem.className = visible ? 'selected' : '';
+      });
+      requestRenderIfNotRequested();
+    }
+
+    const uiElem = document.querySelector('#ui');
+    fileInfos.forEach((info) => {
+      const boxes = addBoxes(info.file, info.hueRange);
+      info.root = boxes;
+      const div = document.createElement('div');
+      info.elem = div;
+      div.textContent = info.name;
+      uiElem.appendChild(div);
+      function show() {
+        showFileInfo(fileInfos, info);
+      }
+      div.addEventListener('mouseover', show);
+      div.addEventListener('touchstart', show);
+    });
+    // show the first set of data
+    showFileInfo(fileInfos, fileInfos[0]);
+  }
+  loadAll();
+
+  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();
+    renderer.render(scene, camera);
+  }
+  render();
+
+  function requestRenderIfNotRequested() {
+    if (!renderRequested) {
+      renderRequested = true;
+      requestAnimationFrame(render);
+    }
+  }
+
+  controls.addEventListener('change', requestRenderIfNotRequested);
+  window.addEventListener('resize', requestRenderIfNotRequested);
+
+  // note: this is a workaround for an OrbitControls issue
+  // in an iframe. Will remove once the issue is fixed in
+  // three.js
+  window.addEventListener('mousedown', (e) => {
+    e.preventDefault();
+    window.focus();
+  });
+  window.addEventListener('keydown', (e) => {
+    e.preventDefault();
+  });
+}
+
+main();
+</script>
+</html>
+