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:




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.



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 draw() function which takes the x, y coordinates of the two end vertices of an edge and draw a line between them.



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.


Finally, to add mouse event listeners to rotate the cube when dragging within canvas:


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.




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 x degree apart from each other and then connect them to make the longitude and latitude lines. On further analyzing the problem, it seems only one set of loops is enough (either latitude or longitude lines) to calculate all vertices. The other set of lines can be readily made by connecting existing vertices.

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:

The getPointsOnCircle() function takes in the radius of a circle and the number of segments x that circle needs to be divided into. Given a circle has 360 degrees, each segment must has 360/x degrees. Therefore, using trigonometry I can calculate the x, y coordiniates of each points when they lie in a 2D plane with the center of the circle at the origin.

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 getPointsOnCircle(), as well as longitude loops, between points on consecutive latitude loops. I use a trick here to mark points on latitude loops from the top to the bottom of the sphere with increasing number values. Therefore for any latitude point with id y, it's immediate neighboring latitude points are at y-1 and y+1 (or y-x+1 for the last point on that loop). It's immediate neighboring longitude points are at y-x and y+x (or are the polar points).

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:

There are many elements in a .OBJ file. The only things I'm interested in are the vertices information (prefixed with v) and face information (prefixed with f). Vertices information is straightforward to obtain as the vertices are stored in the format: v [x coord] [y coord] [z coord]. For edges, I need to desconstruct the stored closed faces of the 3D model, in the format: f [v1] [v2] [v3] ..., into edges and to only retain face connection information (aside from texture and face normal information separated by '/').

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)

Size: Divisions:

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 rotate_ functions following several common guidelines for increasing performances:

Test the performance of each of these improvements using the testPerformance(points) function, which rotates points by X and Y 100000 times, and the results are:

ObjectOriginalGlobal VarIn-place UpdateSingle MatrixBrowser
Sphere with 60 loops337.745ms18.265ms18.255ms16.845msChrome
Sphere with 80 loops345.185ms18.455ms18.535ms17.315msChrome
Sphere with 100 loops331.730ms19.330ms18.400ms17.710msChrome
Sphere with 60 loops569ms14ms14ms9msFirefox
Sphere with 80 loops562ms13ms14ms9msFirefox
Sphere with 100 loops573ms14ms15ms9msFirefox

Pulling out Math.cos and Math.sin computations outside the for loop seems to offer the most performance gains. Combining two matrices together seems to offer another small performance improvement.

Next, we can improve the draw() function by making it asynchronous. We can schedule the function to be called every (1000ms/60)~16.67ms instead of every time the mouse drags inside canvas (which can be over 60Hz). Using setInterval() and clearInterval() when mouse is pressed and released also saves the browser from wasted computations when no updates to the model occurs.

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