Make A 3D-Model Wireframe Viewer
Mar. 23, 2021, 15:12:31
The project started one Saturday morning when I suddenly had the idea of making a "rotating cube" in HTML canvas. Although I knew there were a lot of resources online about it, I wanted to start with only my knowledge and see how far I could go before resorting to external resources eventually.
All code can be found here 3DUtil.js
Representing and Manipulating A Cube in 3D
First I create an HTML page with a canvas of size 600*600:
<html>
<body>
<canvas width=600 height=600>
<script>
// Everything goes here...
</script>
</body>
</html>
I want to draw a cube as the starting point. To represent a cube in 3D, it's intuitive to assign its 8 vertices their individual coordinates in a 3D euclidean space. I define the size of the cube to be 200 pixels with its center at the origin (0,0,0). Then I represent each edge with the name of its 2 vertices.
var points = {
a: [100,100,100],
b: [100,-100,100],
c: [100,100,-100],
d: [-100,100,100],
e: [100,-100,-100],
f: [-100,100,-100],
g: [-100,-100,100],
h: [-100,-100,-100],
};
var edges = [['a', 'b'], ['a', 'c'], ['a', 'd'], ['b', 'e'], ['b', 'g'], ['c', 'e'],
['c', 'f'], ['d', 'f'], ['d', 'g'], ['e', 'h'], ['f', 'h'], ['g', 'h']];
Because HTML canvas has its origin (0, 0) in the upper-left corner, I need to offset the coordinates to make the vertices centered in the canvas element. I also take this chance to implement the
// Get canvas element and set color styles
var canvas = document.getElementById('canvas');
var ctx = canvas.getContext('2d');
ctx.font = "12px Arial";
ctx.fillStyle = '#000000';
// Offsets to shift canvas origin to the center
var WIDTH = canvas.width;
var HEIGHT = canvas.height;
var HAXIS = WIDTH/2;
var VAXIS = HEIGHT/2;
// Draw the edges
function draw() {
// Clear canvas
ctx.clearRect(0, 0, WIDTH, HEIGHT);
for(var i in edges) {
var edge = edges[i];
// Move the points to the center of canvas
var dot1 = [HAXIS+points[edge[0]][0], VAXIS+points[edge[0]][1]];
var dot2 = [HAXIS+points[edge[1]][0], VAXIS+points[edge[1]][1]];
// Draw edges
ctx.beginPath();
ctx.moveTo(dot1[0], dot1[1]);
ctx.lineTo(dot2[0], dot2[1]);
ctx.stroke();
}
}
// Draw cube on page load
draw();
To rotate the cube, I can borrow the concept of a "rotation matrix" from linear algebra. When a rotation matrix is multiplied by a coordinate, I can get the resulting coordinate after the rotation.
More conveniently, rotation matrices A and B can be multiplied together before the resulting matrix is applied to a coordinate. The resulting coordinate is the result of rotating by matrix B and A, in that order (To read more about rotation matrix here).
I could implement a matrix multiplication helper function, but it's simple enough for my case to just implement the rotation by X/Y/Z-axis functions using the result of matrix multiplications.
// points - points to rotate
// degree - degree of rotation in radians
function rotateX(points, degree) {
for(var v in points) {
points[v] = [points[v][0], Math.cos(degree)*points[v][1]-Math.sin(degree)*points[v][2],
Math.sin(degree)*points[v][1]+Math.cos(degree)*points[v][2]];
}
}
function rotateY(points, degree) {
for(var v in points) {
points[v] = [Math.cos(degree)*points[v][0]+Math.sin(degree)*points[v][2], points[v][1],
-Math.sin(degree)*points[v][0]+Math.cos(degree)*points[v][2]];
}
}
function rotateZ(points, degree) {
for(var v in points) {
points[v] = [Math.cos(degree)*points[v][0]-Math.sin(degree)*points[v][1],
Math.sin(degree)*points[v][0]+Math.cos(degree)*points[v][1], points[v][2]];
}
}
Finally, to add mouse event listeners to rotate the cube when dragging within canvas:
// Register mouse event
var rect = canvas.getBoundingClientRect();
var mouseDown = false;
var startX, startY;
// Move 0.5 radians when mouse is moved 1 pixel
var moveRadian = toRadian(0.5);
canvas.onmousedown = function(e) {
mouseDown = true;
startX = e.clientX-rect.left;
startY = e.clientY-rect.top;
}
canvas.onmousemove = function(e) {
if(mouseDown) {
var newX = e.clientX-rect.left;
var newY = e.clientY-rect.top;
rotateY(points, (startX-newX)*moveRadian);
rotateX(points, (newY-startY)*moveRadian);
startX = newX;
startY = newY;
draw();
}
}
canvas.onmouseup = function(e) {
mouseDown = false;
}
// Helper function to get radians from degrees
function toRadian(degree) {
return degree * Math.PI / 180;
}
Example result:
Tangent: More Geometrics
After the initial success, I was thrilled to try out more geometrics. I successfully made a tetrahedron wireframe with some trigonometry. Then I started to think about more complicated geometric shapes like spheres.
// Coordinates for tetrahedron
var size = 2.0 * height / Math.sqrt(3);
var upperHeight = size / Math.sqrt(3);
var lowerHeight = height - upperHeight;
var points = {
a: [0,0,upperHeight],
b: [-size/2,-lowerHeight,-lowerHeight],
c: [size/2,-lowerHeight,-lowerHeight],
d: [0,upperHeight,-lowerHeight]
}
var edges = [['a', 'b'], ['a', 'c'], ['a', 'd'],
['b', 'c'], ['b', 'd'], ['c', 'd']];
Unlike cubes and other polygons which have a fixed number of vertices that can be precalculated and hardcoded, spheres can be divided into infinite vertices and edges. The more vertices it has, the more spherical the resulting shape is (ie. calculus). So this means vertices must be dynamically generated in code.
To make a wireframe sphere, I need to first get the coordinates of vertices on the sphere that is some fixed angle
Another observation is that longitude and latitude lines are all loops/circles. I can make a helper function that calculates points on a circle, given the radius of that circle, which then can be translated/scaled to account for all such loops on the sphere. The final code looks like this:
function getPointsOnCircle(radius, numPoints) {
// Polar point
if(radius === 0) {
return [[0, 0, 0]];
}
// Edge case check
if(radius < 0) return null;
else if(numPoints <= 0) return null;
var gapDegree = ALL_DEGREE / numPoints;
var points = [];
var startPoint = [radius, 0, 0];
for(var i = 0; i < numPoints; i++) {
var newPoint = [[...startPoint]];
rotateZ(newPoint, i*gapDegree)
points.push(newPoint[0]);
}
return points;
}
function getSphere(radius, numLoops) {
// Edge case check
if(radius < 0) return null;
else if(numLoops <= 0) return null;
var points = {};
var edges = [];
var counter = 0;
var gapDegree = ALL_DEGREE / (2 * numLoops);
for(var i = 1; i < numLoops; i++) {
var circlePoints = getPointsOnCircle(Math.cos(Math.PI/2-i*gapDegree)*radius, numLoops);
// Get z coordinate
var z = Math.sin(Math.PI/2 - i*gapDegree) * radius;
// Add latitude vertices and connect them
var startCounter = counter;
for(var j in circlePoints) {
var point = circlePoints[j];
point[2] = z;
// Connect vertices to form longitude loops
if(i !== 1) {
edges.push([counter, counternumLoops]);
}
points[counter++] = point;
}
// Connect vertices to form a circle
for(var j = 0; j < numLoops-1; j++) edges.push([startCounter, ++startCounter]);
// Close the circle
edges.push([startCounter, startCounternumLoops+1]);
}
// Add polar point coordinates
var topPolarCounter = counter;
points[counter++] = [0, 0, radius]
var botPolarCounter = counter;
points[counter++] = [0, 0, -radius]
// Connect polar points to latitude loops
for(var j = 0; j < numLoops; j++) {
edges.push([topPolarCounter, j]);
edges.push([botPolarCounter, topPolarCounter-j-1]);
}
return [points, edges];
}
The
Using this helper function, I can calculate the points on every latitude loop at a specific Z value (again using trig to get different radii from Z values). Once I have all the points, I can translate them up or down the Z-axis to form the latitude loops.
Then I need to connect latitude loops, within the set of points returned by
Finally, I add top and bottom polar points and connect them to the mesh.
Demo: See next section.
.obj File Wireframe Viewer
Given I've done so much work on the project already, I decided to try it on more complicated 3D models that were not necessarily simple geometries.
I learned from a quick search that Waveform .OBJ file is what I need to extract 3D model information of vertices and edges. However, a parser is needed to extract that information.
Inspiration from here:
function parseObjFile(input) {
var lines = input.split('\n');
var points = {};
var counter = 1; // Obj file is 1-based indexing
var edges = [];
for(var i = 0; i < lines.length; i++) {
var parts = lines[i].split(' ');
switch(parts[0]) {
case 'v':
// A vertex, store it
var coordinates = lines[i].split(' ');
var newPoint = [parseFloat(coordinates[1])*initialScale,
parseFloat(coordinates[2])*initialScale,
parseFloat(coordinates[3])*initialScale];
points[counter++] = newPoint;
break;
case 'f':
// A face, record edges
var facePoints = lines[i].split(' ');
var lastPoint = null, currPoint;
for(var j = 1; j < facePoints.length; j++) {
currPoint = facePoints[j].split('/')[0];
if(lastPoint !== null) edges.push([lastPoint, currPoint]);
lastPoint = currPoint;
}
// Closed face
edges.push([lastPoint, facePoints[1].split('/')[0]]);
break;
default:
continue;
}
}
console.log("Loaded", Object.keys(points).length, "vertices and", edges.length, "edges");
return [points, edges];
}
There are many elements in a .OBJ file. The only things I'm interested in are the vertices information (prefixed with
Demo
Now with everything inplace, let's try it out:
Warning: Some .OBJ file has too many vertices. Loading it in may freeze the browser. I recommend starting with 3D models with a small number of vertices, before moving on to those with more vertices.
Here are some 3D model I made in the past using blender that you can try out:
Box in a room (270 vertices)
Saber (4842 vertices)
Rook (13791 vertices)
Optimization
As the number of vertices increases significantly for real 3D models, the performance of the program suffers (ie. staggard canvas refreshing and high response time). It's time for potential optimizations.
It's not hard to reason that the most resource-intense and the most often run piece of code is that responsible for 1)rotating and 2)drawing the vertices.
Here I made 3 improvements to the
// Original rotate
function rotateXO(points, degree) {
for(var v in points) {
points[v] = [points[v][0], Math.cos(degree)*points[v][1]Math.sin(degree)*points[v][2],
Math.sin(degree)*points[v][1]+Math.cos(degree)*points[v][2]];
}
}
// Pull out constants
function rotateXI(points, degree) {
var cosine = Math.cos(degree);
var sine = Math.sin(degree);
for(var v in points) {
points[v] = [points[v][0], cosine*points[v][1]sine*points[v][2],
sine*points[v][1]+cosine*points[v][2]];
}
}
// Make in-place changes
function rotateX(points, degree) {
var cosine = Math.cos(degree);
var sine = Math.sin(degree);
var x, y, z;
for(var v in points) {
x = points[v][0];
y = points[v][1];
z = points[v][2];
points[v][0] = x;
points[v][1] = cosine*ysine*z;
points[v][2] = sine*y+cosine*z;
}
}
// Combine rotation matrix X and Y
function rotateXY(points, x, y) {
var xCosine = Math.cos(x);
var xSine = Math.sin(x);
var yCosine = Math.cos(y);
var ySine = Math.sin(y);
var xSinySin = xSine*ySine;
var xCosySin = ySine*xCosine;
var xSinyCos = xSine*yCosine;
var xCosyCos = xCosine*yCosine;
var x, y, z;
for(var v in points) {
x = points[v][0];
y = points[v][1];
z = points[v][2];
points[v][0] = yCosine*x + xSinySin*y + xCosySin*z;
points[v][1] = xCosine*y xSine*z;
points[v][2] = ySine*x + xSinyCos*y + xCosyCos*z;
}
}
Test the performance of each of these improvements using the
Object | Original | Global Var | In-place Update | Single Matrix | Browser |
Sphere with 60 loops | 337.745ms | 18.265ms | 18.255ms | 16.845ms | Chrome |
Sphere with 80 loops | 345.185ms | 18.455ms | 18.535ms | 17.315ms | Chrome |
Sphere with 100 loops | 331.730ms | 19.330ms | 18.400ms | 17.710ms | Chrome |
Sphere with 60 loops | 569ms | 14ms | 14ms | 9ms | Firefox |
Sphere with 80 loops | 562ms | 13ms | 14ms | 9ms | Firefox |
Sphere with 100 loops | 573ms | 14ms | 15ms | 9ms | Firefox |
Pulling out
Next, we can improve the
Finally, I also made a willful decision on this page to force 3D models with over 1000 vertices to refresh at a maximum of 10Hz. It's somewhat a safety measure against browser freezing.
Rotating Cube
All that being said, I still haven't made a rotating cube yet! So here it is. I've added a new camera matrix, which you can read more about here, to make it look nice and align with what I imagined at the very beginning when I started. Hope you have a great day!
Useful Links/References:
Rotation Matrix - Wikipedia
Reading an .obj file in Processing - wblut
Camera Matrix - Wikipedia