Search code examples
javascriptthree.jshtml5-canvastexturessprite

Three.js: Distant objects are faint


I'm working on a three.js scene that renders some textured point sprites. Those sprites get their textures from a single uniform, and that uniform is a 2D canvas on which I've drawn the letters of the alphabet:

enter image description here

While all the letters are rendered on this canvas in black, points in the three.js scene that are far from the camera appear quite faintly in my scene:

// aliases
var BA = THREE.BufferAttribute,
    IBA = THREE.InstancedBufferAttribute,
    ARR = Float32Array;


function Wordmap() {
  // config
  this.wordScalar = 0.0003; // sizes up words
  this.heightScalar = 0.002; // controls mountain height
  this.sep = 0.9; // separation between characters
  this.maxWords = 1000000; // max number of words to draw
  this.background = '#fff'; // background color
  this.color = '#000'; // text color
  // static
  this.size = 64; // size of each character on canvas
  // state
  this.state = {
    layout: 'grid', // name of the currently active layout
    flying: false, // bool indicating whether we're flying camera
    clock: null, // clock to measure how long we've been flying camera
    transitioning: false, // bool indicating whether layout is transitioning
    transitionQueued: false, // bool indicating whether to run another layout transition
  }
  // data
  this.data = {
    input: null,
    words: [],
    layouts: {},
    heightmap: {},
    characters: {},
  }
  // initialize
  this.init();
}


/**
* Scene
**/


Wordmap.prototype.createScene = function() {
  // generate a scene object
  var scene = new THREE.Scene();

  // generate a camera
  var aspectRatio = window.innerWidth / window.innerHeight;
  var camera = new THREE.PerspectiveCamera(75, aspectRatio, 0.001, 10);

  // generate a renderer
  var renderer = new THREE.WebGLRenderer({antialias: true, alpha: true});
  renderer.sortObjects = false; // make scene.add order draw order
  renderer.setPixelRatio(window.devicePixelRatio);
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.domElement.id = 'gl-scene';
  document.body.appendChild(renderer.domElement);

  // generate controls
  var controls = new THREE.TrackballControls(camera, renderer.domElement);
  controls.zoomSpeed = 0.05;
  controls.panSpeed = 0.1;

  // position the camera
  camera.position.set(0.03, -0.80, 1.3);
  camera.up.set(0.00, 0.32, 0.94);
  camera.quaternion.set({_w: 0.81, _x: 0.58, _y: 0.01, _z: 0.00})
  controls.target.set(0.01, 1.00, 0.24);
  controls.update();

  // add ?axes=true to url to see axis helpers for global orientation
  if (window.location.search.includes('axes=true')) {
    var axesHelper = new THREE.AxesHelper(5);
    scene.add(axesHelper);
  }

  // store objects on instance
  this.scene = scene;
  this.camera = camera;
  this.controls = controls;
  this.renderer = renderer;
}


Wordmap.prototype.render = function() {
  requestAnimationFrame(this.render.bind(this));
  this.renderer.render(this.scene, this.camera);
  this.controls.update();
  if (this.state.transitionQueued) {
    this.state.transitionQueued = false;
    this.updateLayout();
  }
}


Wordmap.prototype.onWindowResize = function() {
  this.camera.aspect = window.innerWidth / window.innerHeight;
  this.camera.updateProjectionMatrix();
  this.renderer.setSize(window.innerWidth, window.innerHeight);
  this.setPointScale();
}


/**
* Character canvas
**/

Wordmap.prototype.setCharacters = function() {
  var canvas = document.createElement('canvas'),
      ctx = canvas.getContext('2d'),
      charToCoords = {},
      yOffset = -0.25, // offset to draw full letters w/ baselines...
      xOffset = 0.05; // offset to draw full letter widths
  canvas.width = this.size * 16; // * 16 because we want 16**2 = 256 letters
  canvas.height = this.size * 16; // must set size before setting font size
  canvas.id = 'letter-canvas';
  ctx.font = this.size + 'px Monospace';
  // draw the letters on the canvas
  ctx.fillStyle = this.color;
  for (var x=0; x<16; x++) {
    for (var y=0; y<16; y++) {
      var char = String.fromCharCode((x*16) + y);
      charToCoords[char] = {x: x, y: y};
      ctx.fillText(char, (x+xOffset)*this.size, yOffset*this.size+(y+1)*this.size);
    }
  }
  // build a three canvas with the canvas
  var tex = new THREE.Texture(canvas);
  tex.flipY = false;
  tex.needsUpdate = true;
  // store the character map on the instance
  this.data.characters = {
    map: charToCoords,
    tex: tex,
  }
}


/**
* Heightmap canvas
**/

Wordmap.prototype.getHeightmap = function(cb) {
  var img = new Image();
  img.crossOrigin = 'Anonymous';
  img.onload = function() {
    var canvas = document.createElement('canvas'),
        ctx = canvas.getContext('2d');
    canvas.width = img.width;
    canvas.height = img.height;
    ctx.drawImage(img, 0, 0);
    cb(ctx.getImageData(0,0, img.width, img.height));
  }
  img.src = 'https://duhaime.s3.amazonaws.com/blog/visualizations/wordmap/heightmap.jpg';
}


/**
* Geometry
**/

Wordmap.prototype.addWords = function() {
  var attrs = this.getWordAttrs(),
      geometry = new THREE.InstancedBufferGeometry();
  geometry.addAttribute('uv', new BA(new ARR([0,0]), 2, true, 1));
  geometry.addAttribute('position', new BA(new ARR([0,0,0]), 3, true, 1));
  geometry.addAttribute('translation', new IBA(attrs.translations, 3, true, 1));
  geometry.addAttribute('target', new IBA(attrs.translations, 3, true, 1));
  geometry.addAttribute('texOffset', new IBA(attrs.texOffsets, 2, true, 1));
  // build the mesh
  this.setShaderMaterial();
  var mesh = new THREE.Points(geometry, this.material);
  mesh.frustumCulled = false;
  mesh.name = 'words';
  this.mesh = mesh;
  this.scene.add(mesh);
}


Wordmap.prototype.getWordAttrs = function() {
  var n = 0, // total number of characters among all words
      layout = this.data.layouts[this.state.layout],
      words = layout.words,
      positions = layout.positions;
  for (var i=0; i<words.length; i++) n += words[i].length;
  // build up word attributes
  var attrs = {
    translations: new Float32Array(n * 3),
    texOffsets: new Float32Array(n * 2),
  }
  var iters = {
    translationIter: 0,
    texOffsetIter: 0,
  }
  // assume each word has x y coords assigned
  for (var i=0; i<words.length; i++) {
    var word = words[i],
        x = positions[i][0],
        y = positions[i][1],
        z = positions[i][2] || this.getHeightAt(x, y);
    for (var c=0; c<word.length; c++) {
      var offsets = this.data.characters.map[word[c]] || this.data.characters.map['?'];
      attrs.translations[iters.translationIter++] = x + (this.wordScalar * this.sep * c);
      attrs.translations[iters.translationIter++] = y;
      attrs.translations[iters.translationIter++] = z;
      attrs.texOffsets[iters.texOffsetIter++] = offsets.x;
      attrs.texOffsets[iters.texOffsetIter++] = offsets.y;
    }
  }
  return attrs;
}


Wordmap.prototype.setShaderMaterial = function() {
  this.material = new THREE.RawShaderMaterial({
    vertexShader: document.getElementById('vertex-shader').textContent,
    fragmentShader: document.getElementById('fragment-shader').textContent,
    uniforms: {
      pointScale: { type: 'f', value: 0.0, },
      cellSize:   { type: 'f', value: this.size / (this.size * 16), }, // letter size in map
      tex:        { type: 't', value: this.data.characters.tex, },
      color:      { type: 'f', value: this.getColorUniform() },
      transition: { type: 'f', value: 0.0, },
    },
    //transparent: true,
    defines: {
      WORDS: true,
    }
  });
  this.setPointScale();
}


Wordmap.prototype.getColorUniform = function() {
  return this.color === '#fff' ? 1.0 : 0.0;
}


Wordmap.prototype.getHeightAt = function(x, y) {
  // because x and y axes are scaled -1:1, rescale 0:1
  x = (x+1)/2;
  y = (y+1)/2;
  var row = Math.floor(y * this.data.heightmap.height),
      col = Math.floor(x * this.data.heightmap.width),
      idx = (row * this.data.heightmap.width * 4) + (col * 4),
      z = (this.data.heightmap.data[idx] + Math.random()) * this.heightScalar;
  return z;
}


Wordmap.prototype.init = function() {
  this.setCharacters();
  this.setBackgroundColor();
  this.getHeightmap(function(heightMapData) {
    this.data.heightmap = heightMapData;
    get('https://duhaime.s3.amazonaws.com/blog/visualizations/wordmap/wordmap-layouts.json', function(data) {
      this.data.input = data;
      this.parseLayouts();
      this.createScene();
      this.addWords();
      this.render();
      setTimeout(this.flyInCamera.bind(this), 500);
      window.addEventListener('resize', this.onWindowResize.bind(this));
    }.bind(this))
  }.bind(this))
}


Wordmap.prototype.parseLayouts = function() {
  for (var i=0; i<this.data.input.length; i++) {
    var l = this.data.input[i],
        name = l.name || i,
        words = l.words,
        positions = this.center(l.positions),
        wordToCoords = {};
    for (var j=0; j<words.length; j++) {wordToCoords[words[j]] = positions[j];}
    this.data.layouts[name] = {
      words: words,
      positions: positions,
      wordToCoords: wordToCoords,
    }
    // activate the first layout
    if (i == 0 && !this.state.layout) this.state.layout = name;
  }
}


// center an array of vertex positions -1:1 on each axis
Wordmap.prototype.center = function(arr) {
  var max = Number.POSITIVE_INFINITY,
      min = Number.NEGATIVE_INFINITY,
      domX = {min: max, max: min},
      domY = {min: max, max: min},
      domZ = {min: max, max: min};
  // find the min, max of each dimension
  for (var i=0; i<arr.length; i++) {
    var x = arr[i][0],
        y = arr[i][1],
        z = arr[i][2] || 0;
    if (x < domX.min) domX.min = x;
    if (x > domX.max) domX.max = x;
    if (y < domY.min) domY.min = y;
    if (y > domY.max) domY.max = y;
    if (z < domZ.min) domZ.min = z;
    if (z > domZ.max) domZ.max = z;
  }
  var centered = [];
  for (var i=0; i<arr.length; i++) {
    var cx = (((arr[i][0]-domX.min)/(domX.max-domX.min))*2)-1,
        cy = (((arr[i][1]-domY.min)/(domY.max-domY.min))*2)-1,
        cz = (((arr[i][2]-domZ.min)/(domZ.max-domZ.min))*2)-1 || null;
    if (arr[i].length == 3) centered.push([cx, cy, cz]);
    else centered.push([cx, cy]);
  }
  return centered;
}


Wordmap.prototype.queryWords = function(s) {
  var map = this.data.layouts[this.state.layout].wordToCoords;
  return Object.keys(map).filter(function(w) {
    return w.toLowerCase().indexOf(s.toLowerCase()) > -1;
  });
}


Wordmap.prototype.updateLayout = function() {
  if (this.state.transitioning) {
    this.state.transitionQueued = true;
    return;
  }
  this.state.transitioning = true;
  this.setPointScale();
  var attrs = this.getWordAttrs();
  this.mesh.geometry.attributes.target.array = attrs.translations;
  this.mesh.geometry.attributes.target.needsUpdate = true;
  TweenLite.to(this.mesh.material.uniforms.transition, 1, {
    value: 1,
    ease: Power4.easeInOut,
    onComplete: function() {
      requestAnimationFrame(function() {
        this.mesh.geometry.attributes.translation.array = attrs.translations;
        this.mesh.geometry.attributes.translation.needsUpdate = true;
        this.mesh.material.uniforms.transition = {type: 'f', value: 0};
        this.state.transitioning = false;
      }.bind(this))
    }.bind(this)
  })
}


/**
* User callbacks
**/

Wordmap.prototype.setBackgroundColor = function() {
  document.querySelector('body').style.background = this.background;
}


Wordmap.prototype.setTextColor = function() {
  this.setCharacters();
  this.mesh.material.uniforms.tex.value = this.data.characters.tex;
  this.mesh.material.uniforms.color.value = this.getColorUniform();
}


Wordmap.prototype.setPointScale = function() {
  var val = window.devicePixelRatio * window.innerHeight * this.wordScalar;
  this.material.uniforms.pointScale.value = val;
  this.material.uniforms.pointScale.needsUpdate = true;
  this.renderer.setPixelRatio(window.devicePixelRatio);
}


Wordmap.prototype.flyTo = function(coords) {
  if (this.state.flying) return;
  this.state.flying = true;
  // pull out target coordinates
  var self = this,
      x = coords[0],
      y = coords[1],
      z = coords[2] || self.getHeightAt(coords[0], coords[1]),
      z = z + 0.015,
      // specify animation duration
      duration = 3,
      // create objects to use during flight
      aspectRatio = window.innerWidth / window.innerHeight,
      _camera = new THREE.PerspectiveCamera(75, aspectRatio, 0.001, 10),
      _controls = new THREE.TrackballControls(_camera, self.renderer.domElement),
      q0 = self.camera.quaternion.clone(),
      _up = self.camera.up;
  _camera.position.set(x, y, z);
  _controls.target.set(x, y, z);
  _controls.update();
  TweenLite.to(self.camera.position, duration, {
    x: x,
    y: y,
    z: z,
    onStart: function() {
      self.state.clock = new THREE.Clock();
      self.state.clock.start();
    },
    onUpdate: function() {
      var deg = self.state.clock.getElapsedTime() / duration;
      THREE.Quaternion.slerp(q0, _camera.quaternion, self.camera.quaternion, deg);
    },
    onComplete: function() {
      var q = _camera.quaternion,
          p = _camera.position,
          u = _camera.up,
          c = _controls.target;
      self.camera.position.set(p.x, p.y, p.z);
      self.camera.up.set(u.x, u.y, u.z);
      self.camera.quaternion.set(q.x, q.y, q.z, q.w);
      self.controls.target = new THREE.Vector3(c.x, c.y, c.z-1.0);
      self.controls.update();
      self.state.flying = false;
    },
    ease: Power4.easeInOut,
  });
}


Wordmap.prototype.flyInCamera = function() {
  TweenLite.to(this.camera.position, 3.5, {
    z: 0.56,
    ease: Power4.easeInOut,
  });
}


Wordmap.prototype.getWordCoords = function(word) {
  return this.data.layouts[this.state.layout].wordToCoords[word];
}

/**
* Typeahaed
**/

function Typeahead() {
  var input = document.querySelector('#search'), // query box
      typeahead = document.querySelector('#typeahead'), // typeahead options
      button = document.querySelector('#search-button'); // submit button

  input.addEventListener('keyup', function(e) {
    clearTypeahead();
    if (e.keyCode == 13 || e.target.value.length < 2) return;
    var matches = wm.queryWords(e.target.value),
        rendered = {}; // store the rendered objects to prevent cased dupes
    for (var i=0; i<Math.min(50, matches.length); i++) {
      if (!(matches[i].toLowerCase().trim() in rendered)) {
        rendered[ matches[i].toLowerCase().trim() ] = true;
        var elem = document.createElement('div');
        elem.textContent = matches[i];
        elem.onclick = function(str, e) {
          input.value = str;
          submit();
        }.bind(this, matches[i]);
        document.querySelector('#typeahead').appendChild(elem);
      }
    }
  })

  function clearTypeahead(e) {
    typeahead.innerHTML = '';
  }

  function submit() {
    if (!input.value) return;
    var coords = wm.getWordCoords(input.value);
    if (!coords) {
      var elem = document.querySelector('#no-results');
      elem.style.transform = 'translate(0, 75px)';
      setTimeout(function() {
        elem.style.transform = 'translate(0, 24px)';
      }, 1500);
      return;
    }
    wm.flyTo(coords);
    clearTypeahead();
  }

  button.addEventListener('click', submit);
  window.addEventListener('click', clearTypeahead);
  input.addEventListener('keydown', function(e) {
    if (e.keyCode == 13) submit();
    else clearTypeahead();
  });
}


/**
* Main
**/

function get(url, onSuccess, onErr, onProgress) {
  var xmlhttp = new XMLHttpRequest();
  xmlhttp.onreadystatechange = function() {
    if (xmlhttp.readyState == XMLHttpRequest.DONE) {
      if (xmlhttp.status === 200) {
        if (onSuccess) onSuccess(JSON.parse(xmlhttp.responseText));
      } else {
        if (onErr) onErr(xmlhttp)
      }
    };
  };
  xmlhttp.onprogress = function(e) {
    if (onProgress) onProgress(e);
  };
  xmlhttp.open('GET', url, true);
  xmlhttp.send();
};



// create the gui
window.onload = function() {
  wm = new Wordmap();
  typeahead = new Typeahead();

  // build the gui
  gui = new dat.GUI({hideable: false})

  gui.add(wm.state, 'layout', ['grid', 'tsne'])
    .name('layout')
    .onFinishChange(wm.updateLayout.bind(wm))

  gui.add(wm, 'wordScalar', 0.0, 0.001)
    .name('font size')
    .onFinishChange(wm.updateLayout.bind(wm))

  gui.add(wm, 'heightScalar', 0.0, 0.003)
    .name('mountain')
    .onFinishChange(wm.updateLayout.bind(wm))

  gui.addColor(wm, 'background')
    .name('background')
    .onChange(wm.setBackgroundColor.bind(wm))

  gui.add(wm, 'color', ['#fff', '#000'])
    .name('color')
    .onChange(wm.setTextColor.bind(wm))
};
html,
body {
  width: 100%;
  height: 100%;
}

body {
  margin: 0;
  overflow: hidden;
}

body::after {
  content: '';
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  background: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.2));
}

canvas {
  position: relative;
  z-index: 10;
}

body .dg.ac {
  z-index: 100;
}

#letter-canvas {
  position: fixed;
  top: 0;
  left: 0;
}

#search-container {
  position: absolute;
  top: 23px;
  left: 50%;
  width: 360px;
  margin-left: -180px;
  font-family: courier, monospace;
  z-index: 100;
}

#search,
#search-button {
  padding: 7px 10px;
  font-size: 16px;
  line-height: 16px;
  box-sizing: border-box;
}

#search,
#search-button,
#search-button::before {
  border-radius: 3px;
}

#search {
  border: 1px solid #aaa;
}

#search-button {
  position: relative;
  opacity: 0.7;
  border: 1px solid #797979;
}

#search-button::before {
  content: '';
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  background: #fff;
  z-index: -1;
}

#search,
#typeahead {
  width: 240px;
  font-family: inherit;
}

#search {
  z-index: 10;
}

#search-button {
  background: #b4cdde;
  color: #485661;
  padding: 8px 18px;
  font-weight: 600;
  letter-spacing: 0.05em;
  font-family: sans-serif;
  cursor: pointer;
}

#typeahead {
  background: #fff;
  max-height: 100px;
  overflow: auto;
  box-sizing: border-box;
  border-bottom-left-radius: 3px;
  border-bottom-right-radius: 3px;
}

.hidden {
  display: none;
}

.displayed {
  display: inline-block;
}

#typeahead div {
  margin: 2px 10px;
  cursor: pointer;
  white-space: nowrap;
}

#typeahead div:hover {
  background: #efefef;
}

#no-results {
  padding: 6px;
  background: firebrick;
  color: #fff;
  font-size: 1em;
  transform: translate(0, 24px);
  display: block;
  margin: 0 auto;
  width: 107px;
  text-align: center;
  position: absolute;
  left: 50%;
  margin-left: -180px;
  z-index: 90;
  font-family: courier;
  border-radius: 3px;
  transition: transform 0.3s;
}
<div id='no-results'>No Results!</div>
<div id='search-container'>
  <div>
    <input id='search' value='pythons'></input>
    <button id='search-button'>SEARCH</button>
  </div>
  <div id='typeahead'></div>
</div>

<script id='vertex-shader' type='x-shader/x-vertex'>
uniform mat4 projectionMatrix;
uniform mat4 modelViewMatrix;
uniform vec3 cameraPosition;
uniform float pointScale;
uniform float transition;

attribute vec3 position;
attribute vec3 translation;
attribute vec3 target;
attribute vec2 texOffset;

varying vec2 vTexOffset;

void main() {
  // project this particle
  vec3 raw0 = position + translation;
  vec3 raw1 = position + target;
  vec3 raw = mix(raw0, raw1, clamp(transition, 0.0, 1.0));
  vec4 mvPosition = modelViewMatrix * vec4(raw, 1.0);
  gl_Position = projectionMatrix * mvPosition;

  // make distant points small
  vec4 cam4 = vec4(cameraPosition, 1.0);
  gl_PointSize = (pointScale / -mvPosition.z);

  vTexOffset = texOffset;
}
</script>

<script id='fragment-shader' type='x-shader/x-fragment'>
precision mediump float;

uniform sampler2D tex;
uniform vec3 fogColor;
uniform float cellSize;
uniform float fogNear;
uniform float fogFar;
uniform float color;

varying vec2 vTexOffset;

void main() {
  #ifdef WORDS
    vec2 uv = vTexOffset + vec2(gl_PointCoord.x, gl_PointCoord.y);
    vec2 scaledUv = uv * vec2(cellSize, cellSize);
    gl_FragColor = texture2D(tex, scaledUv);
    if (gl_FragColor.a < 0.01) discard; // discard non-letter pixels
  #else
    // make point circular
    if (length(gl_PointCoord - vec2(0.5)) > 0.5) discard;
    gl_FragColor = vec4(0.7, 0.7, 0.8, 0.5);
  #endif
}
</script>

<script src='https://duhaime.s3.amazonaws.com/blog/visualizations/wordmap/three.min.js'></script>
<script src='https://duhaime.s3.amazonaws.com/blog/visualizations/wordmap/trackball-controls.min.js'></script>
<script src='https://duhaime.s3.amazonaws.com/blog/visualizations/wordmap/tweenlite.min.js'></script>
<script src='https://duhaime.s3.amazonaws.com/blog/visualizations/wordmap/dat.gui.min.js'></script>

I thought I could just set the alpha attribute of each pixel in the fragment shader to 1 by adding the following as the last line in the fragment shader:

gl_FragColor.a = 1.0;

Doing this makes the displayed font very chunky and pixelated though, and I'd prefer to keep the font displaying the way it is in the visualization above.

I also tried using the above and declaring a lighter font-weight property when creating the 2d canvas full of letters that gets sent as a uniform to the scene, but that didn't work out either.

Does anyone know how I can increase the opacity of distant points/letters without increasing the "weight" of each letter? Any suggestions would be hugely helpful!


Solution

  • This is happening because of the mipmapping that's being applied to your sprite texture. When the letters are mipmapped to smaller resolutions, the black pixels of your text are getting blended to grey.

    You could avoid using the mipmapped texture by changing the .minFilter property of your texture after declaring it, which is what I did in the code snippet below:

    tex.minFilter = THREE.LinearFilter;

    I think the only two options you have for minification filters without mipmapping are LinearFilter and NearestFilter. Keep in mind that disabling mipmapping may give your textures an aliased look.

    Alternatively, you could create your own mipmaps that don't fade to grey in Photoshop, and define them with texture.mipmaps.

    // aliases
    var BA = THREE.BufferAttribute,
        IBA = THREE.InstancedBufferAttribute,
        ARR = Float32Array;
    
    
    function Wordmap() {
      // config
      this.wordScalar = 0.0003; // sizes up words
      this.heightScalar = 0.002; // controls mountain height
      this.sep = 0.9; // separation between characters
      this.maxWords = 1000000; // max number of words to draw
      this.background = '#fff'; // background color
      this.color = '#000'; // text color
      // static
      this.size = 64; // size of each character on canvas
      // state
      this.state = {
        layout: 'grid', // name of the currently active layout
        flying: false, // bool indicating whether we're flying camera
        clock: null, // clock to measure how long we've been flying camera
        transitioning: false, // bool indicating whether layout is transitioning
        transitionQueued: false, // bool indicating whether to run another layout transition
      }
      // data
      this.data = {
        input: null,
        words: [],
        layouts: {},
        heightmap: {},
        characters: {},
      }
      // initialize
      this.init();
    }
    
    
    /**
    * Scene
    **/
    
    
    Wordmap.prototype.createScene = function() {
      // generate a scene object
      var scene = new THREE.Scene();
    
      // generate a camera
      var aspectRatio = window.innerWidth / window.innerHeight;
      var camera = new THREE.PerspectiveCamera(75, aspectRatio, 0.001, 10);
    
      // generate a renderer
      var renderer = new THREE.WebGLRenderer({antialias: true, alpha: true});
      renderer.sortObjects = false; // make scene.add order draw order
      renderer.setPixelRatio(window.devicePixelRatio);
      renderer.setSize(window.innerWidth, window.innerHeight);
      renderer.domElement.id = 'gl-scene';
      document.body.appendChild(renderer.domElement);
    
      // generate controls
      var controls = new THREE.TrackballControls(camera, renderer.domElement);
      controls.zoomSpeed = 0.05;
      controls.panSpeed = 0.1;
    
      // position the camera
      camera.position.set(0.03, -0.80, 1.3);
      camera.up.set(0.00, 0.32, 0.94);
      camera.quaternion.set({_w: 0.81, _x: 0.58, _y: 0.01, _z: 0.00})
      controls.target.set(0.01, 1.00, 0.24);
      controls.update();
    
      // add ?axes=true to url to see axis helpers for global orientation
      if (window.location.search.includes('axes=true')) {
        var axesHelper = new THREE.AxesHelper(5);
        scene.add(axesHelper);
      }
    
      // store objects on instance
      this.scene = scene;
      this.camera = camera;
      this.controls = controls;
      this.renderer = renderer;
    }
    
    
    Wordmap.prototype.render = function() {
      requestAnimationFrame(this.render.bind(this));
      this.renderer.render(this.scene, this.camera);
      this.controls.update();
      if (this.state.transitionQueued) {
        this.state.transitionQueued = false;
        this.updateLayout();
      }
    }
    
    
    Wordmap.prototype.onWindowResize = function() {
      this.camera.aspect = window.innerWidth / window.innerHeight;
      this.camera.updateProjectionMatrix();
      this.renderer.setSize(window.innerWidth, window.innerHeight);
      this.setPointScale();
    }
    
    
    /**
    * Character canvas
    **/
    
    Wordmap.prototype.setCharacters = function() {
      var canvas = document.createElement('canvas'),
          ctx = canvas.getContext('2d'),
          charToCoords = {},
          yOffset = -0.25, // offset to draw full letters w/ baselines...
          xOffset = 0.05; // offset to draw full letter widths
      canvas.width = this.size * 16; // * 16 because we want 16**2 = 256 letters
      canvas.height = this.size * 16; // must set size before setting font size
      canvas.id = 'letter-canvas';
      ctx.font = this.size + 'px Monospace';
      // draw the letters on the canvas
      ctx.fillStyle = this.color;
      for (var x=0; x<16; x++) {
        for (var y=0; y<16; y++) {
          var char = String.fromCharCode((x*16) + y);
          charToCoords[char] = {x: x, y: y};
          ctx.fillText(char, (x+xOffset)*this.size, yOffset*this.size+(y+1)*this.size);
        }
      }
      // build a three canvas with the canvas
      var tex = new THREE.Texture(canvas);
      tex.flipY = false;
      tex.minFilter = THREE.LinearFilter;
      tex.needsUpdate = true;
      // store the character map on the instance
      this.data.characters = {
        map: charToCoords,
        tex: tex,
      }
    }
    
    
    /**
    * Heightmap canvas
    **/
    
    Wordmap.prototype.getHeightmap = function(cb) {
      var img = new Image();
      img.crossOrigin = 'Anonymous';
      img.onload = function() {
        var canvas = document.createElement('canvas'),
            ctx = canvas.getContext('2d');
        canvas.width = img.width;
        canvas.height = img.height;
        ctx.drawImage(img, 0, 0);
        cb(ctx.getImageData(0,0, img.width, img.height));
      }
      img.src = 'https://duhaime.s3.amazonaws.com/blog/visualizations/wordmap/heightmap.jpg';
    }
    
    
    /**
    * Geometry
    **/
    
    Wordmap.prototype.addWords = function() {
      var attrs = this.getWordAttrs(),
          geometry = new THREE.InstancedBufferGeometry();
      geometry.addAttribute('uv', new BA(new ARR([0,0]), 2, true, 1));
      geometry.addAttribute('position', new BA(new ARR([0,0,0]), 3, true, 1));
      geometry.addAttribute('translation', new IBA(attrs.translations, 3, true, 1));
      geometry.addAttribute('target', new IBA(attrs.translations, 3, true, 1));
      geometry.addAttribute('texOffset', new IBA(attrs.texOffsets, 2, true, 1));
      // build the mesh
      this.setShaderMaterial();
      var mesh = new THREE.Points(geometry, this.material);
      mesh.frustumCulled = false;
      mesh.name = 'words';
      this.mesh = mesh;
      this.scene.add(mesh);
    }
    
    
    Wordmap.prototype.getWordAttrs = function() {
      var n = 0, // total number of characters among all words
          layout = this.data.layouts[this.state.layout],
          words = layout.words,
          positions = layout.positions;
      for (var i=0; i<words.length; i++) n += words[i].length;
      // build up word attributes
      var attrs = {
        translations: new Float32Array(n * 3),
        texOffsets: new Float32Array(n * 2),
      }
      var iters = {
        translationIter: 0,
        texOffsetIter: 0,
      }
      // assume each word has x y coords assigned
      for (var i=0; i<words.length; i++) {
        var word = words[i],
            x = positions[i][0],
            y = positions[i][1],
            z = positions[i][2] || this.getHeightAt(x, y);
        for (var c=0; c<word.length; c++) {
          var offsets = this.data.characters.map[word[c]] || this.data.characters.map['?'];
          attrs.translations[iters.translationIter++] = x + (this.wordScalar * this.sep * c);
          attrs.translations[iters.translationIter++] = y;
          attrs.translations[iters.translationIter++] = z;
          attrs.texOffsets[iters.texOffsetIter++] = offsets.x;
          attrs.texOffsets[iters.texOffsetIter++] = offsets.y;
        }
      }
      return attrs;
    }
    
    
    Wordmap.prototype.setShaderMaterial = function() {
      this.material = new THREE.RawShaderMaterial({
        vertexShader: document.getElementById('vertex-shader').textContent,
        fragmentShader: document.getElementById('fragment-shader').textContent,
        uniforms: {
          pointScale: { type: 'f', value: 0.0, },
          cellSize:   { type: 'f', value: this.size / (this.size * 16), }, // letter size in map
          tex:        { type: 't', value: this.data.characters.tex, },
          color:      { type: 'f', value: this.getColorUniform() },
          transition: { type: 'f', value: 0.0, },
        },
        //transparent: true,
        defines: {
          WORDS: true,
        }
      });
      this.setPointScale();
    }
    
    
    Wordmap.prototype.getColorUniform = function() {
      return this.color === '#fff' ? 1.0 : 0.0;
    }
    
    
    Wordmap.prototype.getHeightAt = function(x, y) {
      // because x and y axes are scaled -1:1, rescale 0:1
      x = (x+1)/2;
      y = (y+1)/2;
      var row = Math.floor(y * this.data.heightmap.height),
          col = Math.floor(x * this.data.heightmap.width),
          idx = (row * this.data.heightmap.width * 4) + (col * 4),
          z = (this.data.heightmap.data[idx] + Math.random()) * this.heightScalar;
      return z;
    }
    
    
    Wordmap.prototype.init = function() {
      this.setCharacters();
      this.setBackgroundColor();
      this.getHeightmap(function(heightMapData) {
        this.data.heightmap = heightMapData;
        get('https://duhaime.s3.amazonaws.com/blog/visualizations/wordmap/wordmap-layouts.json', function(data) {
          this.data.input = data;
          this.parseLayouts();
          this.createScene();
          this.addWords();
          this.render();
          setTimeout(this.flyInCamera.bind(this), 500);
          window.addEventListener('resize', this.onWindowResize.bind(this));
        }.bind(this))
      }.bind(this))
    }
    
    
    Wordmap.prototype.parseLayouts = function() {
      for (var i=0; i<this.data.input.length; i++) {
        var l = this.data.input[i],
            name = l.name || i,
            words = l.words,
            positions = this.center(l.positions),
            wordToCoords = {};
        for (var j=0; j<words.length; j++) {wordToCoords[words[j]] = positions[j];}
        this.data.layouts[name] = {
          words: words,
          positions: positions,
          wordToCoords: wordToCoords,
        }
        // activate the first layout
        if (i == 0 && !this.state.layout) this.state.layout = name;
      }
    }
    
    
    // center an array of vertex positions -1:1 on each axis
    Wordmap.prototype.center = function(arr) {
      var max = Number.POSITIVE_INFINITY,
          min = Number.NEGATIVE_INFINITY,
          domX = {min: max, max: min},
          domY = {min: max, max: min},
          domZ = {min: max, max: min};
      // find the min, max of each dimension
      for (var i=0; i<arr.length; i++) {
        var x = arr[i][0],
            y = arr[i][1],
            z = arr[i][2] || 0;
        if (x < domX.min) domX.min = x;
        if (x > domX.max) domX.max = x;
        if (y < domY.min) domY.min = y;
        if (y > domY.max) domY.max = y;
        if (z < domZ.min) domZ.min = z;
        if (z > domZ.max) domZ.max = z;
      }
      var centered = [];
      for (var i=0; i<arr.length; i++) {
        var cx = (((arr[i][0]-domX.min)/(domX.max-domX.min))*2)-1,
            cy = (((arr[i][1]-domY.min)/(domY.max-domY.min))*2)-1,
            cz = (((arr[i][2]-domZ.min)/(domZ.max-domZ.min))*2)-1 || null;
        if (arr[i].length == 3) centered.push([cx, cy, cz]);
        else centered.push([cx, cy]);
      }
      return centered;
    }
    
    
    Wordmap.prototype.queryWords = function(s) {
      var map = this.data.layouts[this.state.layout].wordToCoords;
      return Object.keys(map).filter(function(w) {
        return w.toLowerCase().indexOf(s.toLowerCase()) > -1;
      });
    }
    
    
    Wordmap.prototype.updateLayout = function() {
      if (this.state.transitioning) {
        this.state.transitionQueued = true;
        return;
      }
      this.state.transitioning = true;
      this.setPointScale();
      var attrs = this.getWordAttrs();
      this.mesh.geometry.attributes.target.array = attrs.translations;
      this.mesh.geometry.attributes.target.needsUpdate = true;
      TweenLite.to(this.mesh.material.uniforms.transition, 1, {
        value: 1,
        ease: Power4.easeInOut,
        onComplete: function() {
          requestAnimationFrame(function() {
            this.mesh.geometry.attributes.translation.array = attrs.translations;
            this.mesh.geometry.attributes.translation.needsUpdate = true;
            this.mesh.material.uniforms.transition = {type: 'f', value: 0};
            this.state.transitioning = false;
          }.bind(this))
        }.bind(this)
      })
    }
    
    
    /**
    * User callbacks
    **/
    
    Wordmap.prototype.setBackgroundColor = function() {
      document.querySelector('body').style.background = this.background;
    }
    
    
    Wordmap.prototype.setTextColor = function() {
      this.setCharacters();
      this.mesh.material.uniforms.tex.value = this.data.characters.tex;
      this.mesh.material.uniforms.color.value = this.getColorUniform();
    }
    
    
    Wordmap.prototype.setPointScale = function() {
      var val = window.devicePixelRatio * window.innerHeight * this.wordScalar;
      this.material.uniforms.pointScale.value = val;
      this.material.uniforms.pointScale.needsUpdate = true;
      this.renderer.setPixelRatio(window.devicePixelRatio);
    }
    
    
    Wordmap.prototype.flyTo = function(coords) {
      if (this.state.flying) return;
      this.state.flying = true;
      // pull out target coordinates
      var self = this,
          x = coords[0],
          y = coords[1],
          z = coords[2] || self.getHeightAt(coords[0], coords[1]),
          z = z + 0.015,
          // specify animation duration
          duration = 3,
          // create objects to use during flight
          aspectRatio = window.innerWidth / window.innerHeight,
          _camera = new THREE.PerspectiveCamera(75, aspectRatio, 0.001, 10),
          _controls = new THREE.TrackballControls(_camera, self.renderer.domElement),
          q0 = self.camera.quaternion.clone(),
          _up = self.camera.up;
      _camera.position.set(x, y, z);
      _controls.target.set(x, y, z);
      _controls.update();
      TweenLite.to(self.camera.position, duration, {
        x: x,
        y: y,
        z: z,
        onStart: function() {
          self.state.clock = new THREE.Clock();
          self.state.clock.start();
        },
        onUpdate: function() {
          var deg = self.state.clock.getElapsedTime() / duration;
          THREE.Quaternion.slerp(q0, _camera.quaternion, self.camera.quaternion, deg);
        },
        onComplete: function() {
          var q = _camera.quaternion,
              p = _camera.position,
              u = _camera.up,
              c = _controls.target;
          self.camera.position.set(p.x, p.y, p.z);
          self.camera.up.set(u.x, u.y, u.z);
          self.camera.quaternion.set(q.x, q.y, q.z, q.w);
          self.controls.target = new THREE.Vector3(c.x, c.y, c.z-1.0);
          self.controls.update();
          self.state.flying = false;
        },
        ease: Power4.easeInOut,
      });
    }
    
    
    Wordmap.prototype.flyInCamera = function() {
      TweenLite.to(this.camera.position, 3.5, {
        z: 0.56,
        ease: Power4.easeInOut,
      });
    }
    
    
    Wordmap.prototype.getWordCoords = function(word) {
      return this.data.layouts[this.state.layout].wordToCoords[word];
    }
    
    /**
    * Typeahaed
    **/
    
    function Typeahead() {
      var input = document.querySelector('#search'), // query box
          typeahead = document.querySelector('#typeahead'), // typeahead options
          button = document.querySelector('#search-button'); // submit button
    
      input.addEventListener('keyup', function(e) {
        clearTypeahead();
        if (e.keyCode == 13 || e.target.value.length < 2) return;
        var matches = wm.queryWords(e.target.value),
            rendered = {}; // store the rendered objects to prevent cased dupes
        for (var i=0; i<Math.min(50, matches.length); i++) {
          if (!(matches[i].toLowerCase().trim() in rendered)) {
            rendered[ matches[i].toLowerCase().trim() ] = true;
            var elem = document.createElement('div');
            elem.textContent = matches[i];
            elem.onclick = function(str, e) {
              input.value = str;
              submit();
            }.bind(this, matches[i]);
            document.querySelector('#typeahead').appendChild(elem);
          }
        }
      })
    
      function clearTypeahead(e) {
        typeahead.innerHTML = '';
      }
    
      function submit() {
        if (!input.value) return;
        var coords = wm.getWordCoords(input.value);
        if (!coords) {
          var elem = document.querySelector('#no-results');
          elem.style.transform = 'translate(0, 75px)';
          setTimeout(function() {
            elem.style.transform = 'translate(0, 24px)';
          }, 1500);
          return;
        }
        wm.flyTo(coords);
        clearTypeahead();
      }
    
      button.addEventListener('click', submit);
      window.addEventListener('click', clearTypeahead);
      input.addEventListener('keydown', function(e) {
        if (e.keyCode == 13) submit();
        else clearTypeahead();
      });
    }
    
    
    /**
    * Main
    **/
    
    function get(url, onSuccess, onErr, onProgress) {
      var xmlhttp = new XMLHttpRequest();
      xmlhttp.onreadystatechange = function() {
        if (xmlhttp.readyState == XMLHttpRequest.DONE) {
          if (xmlhttp.status === 200) {
            if (onSuccess) onSuccess(JSON.parse(xmlhttp.responseText));
          } else {
            if (onErr) onErr(xmlhttp)
          }
        };
      };
      xmlhttp.onprogress = function(e) {
        if (onProgress) onProgress(e);
      };
      xmlhttp.open('GET', url, true);
      xmlhttp.send();
    };
    
    
    
    // create the gui
    window.onload = function() {
      wm = new Wordmap();
      typeahead = new Typeahead();
    
      // build the gui
      gui = new dat.GUI({hideable: false})
    
      gui.add(wm.state, 'layout', ['grid', 'tsne'])
        .name('layout')
        .onFinishChange(wm.updateLayout.bind(wm))
    
      gui.add(wm, 'wordScalar', 0.0, 0.001)
        .name('font size')
        .onFinishChange(wm.updateLayout.bind(wm))
    
      gui.add(wm, 'heightScalar', 0.0, 0.003)
        .name('mountain')
        .onFinishChange(wm.updateLayout.bind(wm))
    
      gui.addColor(wm, 'background')
        .name('background')
        .onChange(wm.setBackgroundColor.bind(wm))
    
      gui.add(wm, 'color', ['#fff', '#000'])
        .name('color')
        .onChange(wm.setTextColor.bind(wm))
    };
    html,
    body {
      width: 100%;
      height: 100%;
    }
    
    body {
      margin: 0;
      overflow: hidden;
    }
    
    body::after {
      content: '';
      position: fixed;
      top: 0;
      right: 0;
      bottom: 0;
      left: 0;
      background: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.2));
    }
    
    canvas {
      position: relative;
      z-index: 10;
    }
    
    body .dg.ac {
      z-index: 100;
    }
    
    #letter-canvas {
      position: fixed;
      top: 0;
      left: 0;
    }
    
    #search-container {
      position: absolute;
      top: 23px;
      left: 50%;
      width: 360px;
      margin-left: -180px;
      font-family: courier, monospace;
      z-index: 100;
    }
    
    #search,
    #search-button {
      padding: 7px 10px;
      font-size: 16px;
      line-height: 16px;
      box-sizing: border-box;
    }
    
    #search,
    #search-button,
    #search-button::before {
      border-radius: 3px;
    }
    
    #search {
      border: 1px solid #aaa;
    }
    
    #search-button {
      position: relative;
      opacity: 0.7;
      border: 1px solid #797979;
    }
    
    #search-button::before {
      content: '';
      position: absolute;
      top: 0;
      right: 0;
      bottom: 0;
      left: 0;
      background: #fff;
      z-index: -1;
    }
    
    #search,
    #typeahead {
      width: 240px;
      font-family: inherit;
    }
    
    #search {
      z-index: 10;
    }
    
    #search-button {
      background: #b4cdde;
      color: #485661;
      padding: 8px 18px;
      font-weight: 600;
      letter-spacing: 0.05em;
      font-family: sans-serif;
      cursor: pointer;
    }
    
    #typeahead {
      background: #fff;
      max-height: 100px;
      overflow: auto;
      box-sizing: border-box;
      border-bottom-left-radius: 3px;
      border-bottom-right-radius: 3px;
    }
    
    .hidden {
      display: none;
    }
    
    .displayed {
      display: inline-block;
    }
    
    #typeahead div {
      margin: 2px 10px;
      cursor: pointer;
      white-space: nowrap;
    }
    
    #typeahead div:hover {
      background: #efefef;
    }
    
    #no-results {
      padding: 6px;
      background: firebrick;
      color: #fff;
      font-size: 1em;
      transform: translate(0, 24px);
      display: block;
      margin: 0 auto;
      width: 107px;
      text-align: center;
      position: absolute;
      left: 50%;
      margin-left: -180px;
      z-index: 90;
      font-family: courier;
      border-radius: 3px;
      transition: transform 0.3s;
    }
    <div id='no-results'>No Results!</div>
    <div id='search-container'>
      <div>
        <input id='search' value='pythons'></input>
        <button id='search-button'>SEARCH</button>
      </div>
      <div id='typeahead'></div>
    </div>
    
    <script id='vertex-shader' type='x-shader/x-vertex'>
    uniform mat4 projectionMatrix;
    uniform mat4 modelViewMatrix;
    uniform vec3 cameraPosition;
    uniform float pointScale;
    uniform float transition;
    
    attribute vec3 position;
    attribute vec3 translation;
    attribute vec3 target;
    attribute vec2 texOffset;
    
    varying vec2 vTexOffset;
    
    void main() {
      // project this particle
      vec3 raw0 = position + translation;
      vec3 raw1 = position + target;
      vec3 raw = mix(raw0, raw1, clamp(transition, 0.0, 1.0));
      vec4 mvPosition = modelViewMatrix * vec4(raw, 1.0);
      gl_Position = projectionMatrix * mvPosition;
    
      // make distant points small
      vec4 cam4 = vec4(cameraPosition, 1.0);
      gl_PointSize = (pointScale / -mvPosition.z);
    
      vTexOffset = texOffset;
    }
    </script>
    
    <script id='fragment-shader' type='x-shader/x-fragment'>
    precision mediump float;
    
    uniform sampler2D tex;
    uniform vec3 fogColor;
    uniform float cellSize;
    uniform float fogNear;
    uniform float fogFar;
    uniform float color;
    
    varying vec2 vTexOffset;
    
    void main() {
      #ifdef WORDS
        vec2 uv = vTexOffset + vec2(gl_PointCoord.x, gl_PointCoord.y);
        vec2 scaledUv = uv * vec2(cellSize, cellSize);
        gl_FragColor = texture2D(tex, scaledUv);
        if (gl_FragColor.a < 0.01) discard; // discard non-letter pixels
      #else
        // make point circular
        if (length(gl_PointCoord - vec2(0.5)) > 0.5) discard;
        gl_FragColor = vec4(0.7, 0.7, 0.8, 0.5);
      #endif
    }
    </script>
    
    <script src='https://duhaime.s3.amazonaws.com/blog/visualizations/wordmap/three.min.js'></script>
    <script src='https://duhaime.s3.amazonaws.com/blog/visualizations/wordmap/trackball-controls.min.js'></script>
    <script src='https://duhaime.s3.amazonaws.com/blog/visualizations/wordmap/tweenlite.min.js'></script>
    <script src='https://duhaime.s3.amazonaws.com/blog/visualizations/wordmap/dat.gui.min.js'></script>