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:
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!
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>