3D Graphics in Vanilla JS
Why Do 3D in Raw JS?
The shortest answers would be: "for fun" or "it was kind of an accident", but a lot of what I've learned comes from making little personal projects to amuse myself, and this is a collection of some of those projects. I stumbled across a fairly simple way to show a 3d space and iterated from there until reaching a full 3d render system. As a result the system can be easily understood by just explaining the progression of the code and the ideas from one project to another. Ultimately these experiments culminated in the full 3d render system I used in my JS engine project, which is a nice encapsulation of many of the things I've learned along the way.
The First 3D-esque Project
The underlying numbers here are coming from my first attempt at a procedural random number generator. My actual goal was to find a way to visualize the pseudo-random numbers coming out of the function to see if I had implemented it right. I reckoned that periodicity would be fairly visible in a 3d graph so I tried making something 3d-ish, using simple line segments between points in an HTML canvas.
All that I had implemented was tilting x and y to put the plane on a diagonal, squishing the y down by a factor to create the illusion of tilting the plane, and then add z to y to add height.
x = x - y;
y = x + y;
y /= 2;
y += z;
The last step here was just to scale each of these numbers by some display constant, and set them relative to the center of the canvas.
Much to my surprise this did a fairly good job of replicating the look of 3 dimensional space, and with the wire frame graphics it has a sort of tron-like look that made the visualizations more fun to work on. I became more interested in this than in the periodicity it had revealed, and as I continued to use it I started to find better ways to implement the same basic system.
Miscellaneous Projects and Iterative Improvement
The next handful of projects included: fixing the procedural random, trying to visualise object, a simulation of gravity between objects in space, and a bunch of dots playing tag.
The gravitation simulation is really fun to watch, but the most meaningful difference here is that the main grid can spin now. I started by converting positions into radial coordinates, which made it very easy to add the current rotation, then convert back to the old display system
inRadial = cartesianToRadial(x, y);
inRadial.rotation += cameraRotation;
inCartesian = radialToCartesian(inRadial);
x = inCartesian.x;
y = inCartesian.y;
The above is a bit messy, but it worked and was how these initial projects ran.
This is already very close to the fully functional version, and each of the next steps fell into place easily enough: control camera height, allow the camera to move, allow for 3d models, add perspective, and fix z-fighting.
First controlling camera height-angle. If we consider that all z-coordinates should have no influence if the camera's angle in directly down, and full influence if the camera is in the plane, and a smooth curve between, we can see that camera angle is as simple as changing our "add z" step to be:
y += z * cos(cameraHeightAngle);
Allowing the camera to move just requires it to have an x,y and z of it's own. And now all display x, y, and z are relative to the camera's x, y, and z.
Adding perspective is shockingly easy, and adds so much to the look and feel. Almost all that is needed is to take the intended coordinate and divide it by the euclidean distance from camera. This will now create a bug though where all things behind the camera also display in front of the camera, except upside down. I tried a lot of fancy equations, but in the end just hiding anything behind the camera solved it perfectly.
distance = euclideanDistance(x, y, z, cameraX, cameraY, cameraZ);
displayX /= distance;
displayY /= distance;
Finally fix z-fighting. Z-fighting is when objects get drawn in the wrong order, causing something that should be behind something else, to appear in front of it instead. This one is so simple in theory, but took me a lot longer for one simple reason: bad abstraction. All that needs to happen is: sort objects by distance from the camera, and draw in reverse order. I needed to first create a list of all the line segments to draw, but once I rewrote the display code in that way, I could quite literally just call JS's Array.sort().
I've left 3D models out of the previous section, because the way I have them implemented is easily the single most complex part of the system. The basic idea is to track each graphic as a set of line segments defined by their offset from the center of the object. Then by tracking the position of the object, and it's rotation. We can find the point position of each end of the line segment in space, then using the above code find the display position for each point, and finally use the canvas to draw a line between them.
What I Got Right:
The visual result, everything looks exactly as it should.
A lot of my earliest projects had render methods in the object classes, separating rendering into its own separate bit of logic, and having each object only have an associated graphic has made all of my projects much easier to add complex displays to ever since.
What I Would Do Differently:
To get the obvious out of the way: use an actual game engine. I like understanding how all the parts work, but there are already game engines that can do all of this and more. While a part of me wants to refactor and has ideas about how to add polygon support here, it's not really necessary, and I recognize that there are other places to put my time.
That said, I'd support a pre-existing graphics format. The nature of the evolving code was that it grew in a bit of a lopsided way, and many of the systems were just build one on top of another. The custom graphics format grew with it, but was such a mess in the end that I couldn't reasonably debug a graphic unless I wanted to write a custom editor.
The same basic problem cropped up elsewhere too: code that was just patch on top of patch. While legacy code is a common problem, here a lot of time and effort could have been saved by spending a little bit of time cleaning up old code before charging forward. This has been a repeated lesson, but also a big influence moving forward.
Aug 18, 2021