4D Type Extrusion
NType is a website for visualizing 4th-dimension extrusions of letterforms. It is rendered with WebGL and Three.js, and uses opentype.js to write and download open type files.
Controls on ntype.blue:
- Letters Type any letters to see them in 4D.
- Backspace Use backspace to remove letters.
- Space Pause and play the animation.
- Scrolling While Paused Scrub through the animation.
- Control Panel Click the icon in the bottom right to open the control panel and access many different variables of the extrusion visualization.
- Share Message Generate a shortened URL to be used for sending the current text and settings through hyperspace to a friend
- Download Rotation As OTF Get a working OTF of the full character set rotated to the current position. This doesn't quite always work--scroll scrubbing throws off the rotation amount, as does adding new planes mid-rotation.
I'm going to try to document this well, because there aren't that many resources for 4D graphics. I'm sure that I do a lot of this poorly or inefficiently, but I figured I should go ahead and document it anyway.
NType uses two open-source libraries:
- Three.js Handles 3D rendering, matrix math, etc
- Opentype.js Handles writing and exporting .otf files
NType starts with a set of 2D block letters which were designed specifically to maintain legibility through complex extrusion with the fewest number of vertices. These letters were originally drawn on 28x24 unit grid in Illustrator, and then their vertices were manually copied into the file type.js.
window.TYPE = {};
window.TYPE.A = [
[2,0],
[12,24],
[17,24],
[27,0],
[22,0],
[20,5],
[9,5],
[7,0]
]
// ... etc.
To make the extrusion simpler, the first step will be to normalize the entire set of letterforms such that the maximum vertex value across the whole alphabet is 1. In NType.prototype.utils
there is a function called normalizeLetterSet
. The first action taken by this function is to loop through each letter in the set, determine its offset from 0 on the leftmost side (many of the letters are centered in the 28x24 grid), and move all the vertices so that the shape begins at 0.
Next the function loops through each vertex in each property of the object passed to it (in this case the window.TYPE
object) and finds the maximum value. Once the maximum vertex value in the set has been established, the set is again looped through and each value is divided by the maximum value to scale the full set down to a 0-1 range (except for Q, the only letterform in this set with a negative value).
Finally, the width and height of each vertex set is measured again in their new position, and the entire set is again translated by -1/2 of its width and height, so that now the center of the letterform is at [0,0]
, which will make rotations much simpler.
In order to deal with these forms, we'll need to keep track of more than just the vertices themselves, we'll also need an index of which vertices are connected to which other vertices. To start out, with our simple 2D letterform, each vertex is connected to the next vertex in the array, and the final vertex is connected back around to the first.
In NType.js
there's a data structure called SimpleDimensionalObject
. This just serves as a place to hold all of the related pieces of data we'll need to keep track of. For now, we need it to keep track of our vertices and our joins index.
To start out, the joins index should just be an array of vertex connections representing the outline of our shape. For example, a square would have the following vertices and joins:
var square = new SimpleDimensionalObject();
square.vertices = [
[0,1],
[1,1],
[1,0],
[0,0]
];
square.joins = [
[0,1],
[1,2],
[2,3],
[3,0]
];
We can generate these joins pretty easily by looping through our vertices array and pushing [i, i+1]
to the joins array with a special handler for the last item that pushes [i, 0]
instead.
In implementation, the extrusions are all handled by an instance of NType
, which handles extrusion, rotation, and rendering, for documentation I'm just going to explain these steps irrespective to the specifics of their implementation
The actual extrusion process is really pretty simple. Now that the letter is of unit size, extrusion is a matter of adding a third dimension to the 2D vertices, copying these vertices, and translating the new vertices one unit in the z
dimension. But, since we're trying to center the form on the origin point [0,0]
, we should set the z
vertices for each set to -0.5
and 0.5
instead of 0
and 1
.
The other thing we need to keep track of when performing this translation is the joins. First we'll need to add the joins that connect the translated vertices to themselves. To do this, we can use our [i, i+1]
loop from above, but instead of simply using i
, we'll need to make sure we're referencing the second set of vertices by pushing joins for [i + vertices.length/2, i + 1 + vertices.length/2]
.
Now we'll need to connect the original vertices to the cloned vertices. We know that each vertex pair is offset from each other by half of the length of the vertices
array they live in. So, for each of the original vertices we'll push [i, i+vertices.length/2]
to the joins
array.
This pattern should reliably take a set of 2D vertices and joins and return a set of 3D vertices and joins that represent a unit-length extrusion of the 2D form into 3D space.
Surprisingly, once the mechanics for extruding to 3D have been established, extruding to 4D is a relatively simple proposition. The idea is the same: take a vertex set of n
dimensions, copy it, add a new dimension (offsetting the copied set by 1 unit). To compute the joins, the idea is again the same: first copy the joins from the first set and add 1/2 of the (new, 4D) vertices
array length to them, then make a new join between each vertex i
in the original vertex set and its corresponding i + vertices.length/2
vertex in the new set.
This two-step extrusion will programatically create a set of 4D vertices and joins from each of our flat letterforms.
Now that we have our 4D extrusion, there are two things we want to do to it. We want to render it in its current position, and we want to begin to rotate it (because 4D shapes look much cooler when you rotate them on the xw, yw, or zw planes).
First, we're going to transform the data from a simple n-dimensional array into a Three.js Vector4
object, since we want to use these to project into 3D and then draw our projection in 3D space.
Once our data has been transformed, what makes sense is to preserve this first set of vertices in a new array on the SimpleDimensionalObject
we've made called originalVertices
. Once we've done that, we can mess with the vertices
array as much as we want and we'll always be able to get the original extrusion back.
We also want to add a new property to SimpleDimensionalObject
called projection
. This will hold the three dimensional vertices of our projected 4D vertex set.
I'm no mathematician, but it seems that, because nobody has ever actually seen 4D, we can just make up a rule for projecting the 4th dimension back into the third, and as long as this rule is applied uniformly and consistently we will create some sort of representation of the 4th dimension in a three dimensional space.
The function for projection used in NType is this:
projectW : function(v4) {
var skew = (v4.w * .9) + 2;
return new THREE.Vector3(
v4.x * skew,
v4.y * skew,
v4.z * skew
)
}
The constants in that function are simply a result of tweaking and looking at the result for a while, and don't really mean anything. I'm sure there's a more proper way to handle this, but since nobody can actually verify what 4th dimensional perspective is, this seems to do the trick okay for visualizing rotations.
So, to project our shape we will apply this function to all of its vertices and add the return values to the projection
property.
To render our shape, we'll need to start to set up some of the standard Three.js scene things. In NType these are all properties of the NType instance. I won't go into too much detail on this setup as it is well documented elsewhere, here is what is created during the instantiation of a new NType object.
this.scene = new THREE.Scene();
this.w = window.innerWidth;
this.h = window.innerHeight;
// NType has two cameras initialized for easy switching, but only ORTHO is used right now
this.ORTHO = new THREE.OrthographicCamera( this.w / - 2, this.w / 2, this.h / 2, this.h / - 2, -2000, 2000 );
this.PERSP = new THREE.PerspectiveCamera( 75, this.w / this.h, 0.1, 1000 );
this.camera = this.ORTHO;
this.renderer = new THREE.WebGLRenderer({
antialias : true
});
Then, we need to set up a rendering loop, which is called NType.begin()
this.begin = function() {
// must bind to this in order to preserve context
window.requestAnimationFrame(this.begin.bind(this));
if (!window.PAUSED) {
this.rotate();
this.updateLines();
this.updateTrails();
}
this.renderer.render(this.scene, this.camera);
}
For now, ignore everything but requestAnimationFrame
, this.renderer.render
, and this.updateLines
. requestAnimationFrame
and renderer.render
are the standard pattern for creating a rendering loop in Three.js, so there's not too much to discuss there.
updateLines()
is really the important thing to look at now. This is the function that actually creates, updates, and adds our projection to the scene. The updateLines
function looks like this:
this.updateLines = function() {
var that = this;
this.shapes.forEach(function(s){
if (s.lines.length == 0)
that.addLines(s);
s.lines.forEach(function(l, i) {
// each vertex corresponds to part of the joins array at the
// same position as this iteration
l.geometry.vertices = s.joins[i].reduce(function(a, j) {
a.push(s.projection[j]);
return a;
}, []);
l.geometry.verticesNeedUpdate = true;
});
});
}
It is essentially a nested loop, first looping through each SimpleDimensionalObject
in this.shapes
, and then looping through each line
in a new property on the SimpleDimensionalObject
called lines
. Within the shapes loop, we first check to see if the shape has lines associated with it. If it doesn't, we need to add some lines to it, so we call addLines
on the current instance of NType
, passing in a reference to the current shape
object. Add lines looks like this:
this.addLines = function(s) {
var that = this;
s.lines = s.joins.map(function(j, i) {
var lineGeo = new THREE.Geometry();
lineGeo.vertices = j.map(function(v) {
return s.projection[v];
});
// that.materials is a tiny library of three.js material objects elsewhere in NType
var lineMaterial = that.materials.line;
var line = new THREE.Line(lineGeo, lineMaterial);
that.scene.add(line);
return line;
});
}
For each join on the SimpleDimensionalObject
, we need to create a line linking the two vertices it references. To do this, we first create an empty geometry for each join. Then, we set the vertices of that geometry to the values of the projection
property at the indices in the current join
. We then create a line using THREE.Line
, add it to the scene, and return a reference to the line to be stored in the shape.line
property.
Now that we've added all the lines, we can proceed with the updateLines
function. The rest of updateLines
just updates all of the line vertices to match the projection
property of each SimpleDimensionalObject
in this.shapes
, and tells Three.js that the vertices have been updated.
Now we have a static drawing of a 3D projection of a 4D shape! We did it! Unfortunately, this looks boring if you don't rotate it any. So,
This will just cover the mechanics of rotating the 4D vertices. Three.js actually makes this pretty easy with its Matrix4
object. In NType, the set up is somewhat complicated because it needs to be able to dynamically generate rotation matrices as the user interacts with the controls, but the basis for that system is a set of six rotation matrices, each of which represents one of the six 4D rotation planes (xy,xz,yz,xw,yw,zw). I found these matrices here, in an excellent article on four-space.. Here's how to import those matrices into Three.js:
// t is just a rotation speed constant
var t = Math.PI/200
var zw = new THREE.Matrix4().set(
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, cos(t),-sin(t),
0, 0, sin(t), cos(t)
);
}
Once all of the matrices have been added, combining them is done as so:
var compoundRotationMatrix = new THREE.Matrix4().multiply(zw).multiply(xy) // etc
Once we have the rotation matrix we're looking for, we need to apply it to the 4D shape and re-project the new positions of its vertices. This is done in the function Ntype.rotate
, which you'll remember is called in the Ntype.begin
rendering function.
Ntype.rotate
looks something like this:
this.rotate = function() {
var that = this,
_matrix = this.matrix;
this.shapes.forEach(function(s) {
s.vertices.forEach(function(v) {
v.applyMatrix4(_matrix)
});
});
this.project();
}
It loops through each shape on the NType instance, and applies our compound rotation matrix to each vertex of each shape. Then it calls this.project
to recalculate the projection of the shape for the new vertex positions. Now, when we call this.updateLines
from this.begin
, the lines' vertices will be moved to reflect the new rotation.