Skip to content Skip to sidebar Skip to footer

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 draw

Solution 1:

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>

Post a Comment for "Three.js: Distant Objects Are Faint"