我正在研究一个Three.js scene,它可以渲染一些纹理点精灵。这些精灵从一个单独的uniform
中获取纹理,而该制服是2D画布,在上面绘制了字母:
虽然所有字母都在此画布上以黑色显示,但是Three.js场景中距离相机远的点在我的场景中显得很隐蔽:
// 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>
我以为我可以通过将以下内容添加为片段着色器的最后一行来将片段着色器中每个像素的alpha
属性设置为1:
gl_FragColor.a = 1.0;
这样做会使显示的字体非常粗大且像素化,我更希望使字体以上面的可视化方式显示。
我还尝试使用上面的方法,并在创建充满字母的2d画布时声明了一个较轻的font-weight属性,这些字母被作为制服发送到场景,但这也没有解决。
有人知道我如何在不增加每个字母“权重”的情况下增加远点/字母的不透明度吗?任何建议都将大有帮助!
答案 0 :(得分:4)
之所以发生这种情况,是因为将mipmapping应用于您的sprite纹理。当字母被映射为较小的分辨率时,文本的黑色像素将变为灰色。
您可以通过在声明纹理后更改其.minFilter
属性来避免使用mipmapped纹理,这是我在以下代码段中所做的:
tex.minFilter = THREE.LinearFilter;
我认为没有进行映射的缩小过滤器的only two options you have是LinearFilter
和NearestFilter
。请记住,禁用mipmapping可能会使您的纹理具有锯齿状外观。
或者,您可以创建自己的mipmap,使它们在Photoshop中不会褪色为灰色,并使用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>