3D from Scratch Day 3 - TypeScript, Refactoring, more Cubes!
This is my attempt to figure out enough 3D graphics from scratch to clone the original Elite. The start of the series is here: 3D from Scratch - Intro.
Some smaller bits and bobs today.
Fixes for Chrome, Firefox
The demos only worked in Safari, because Safari does stuff weirdly and I’d coded for that weirdness (I’m a Safari user). In particular this code doesn’t work in Chrome or Firefox:
document.addEventListener('keydown', function(e) {
if (e.keyIdentifier == "Up")
keyState.up = true
...
}
This is because keyIdentifier
is not standard, it’s only in Safari. And "Up"
is not standard, in Chrome and Firefox it’s "ArrowUp"
.
So to make this code portable I’ve changed it to:
document.addEventListener('keydown', function(e) {
var keyName = e.key || e.keyIdentifier
if (keyName == "ArrowUp" || keyName == "Up")
keyState.up = true
...
}
I’ve also gone back and changed this code in the Day 1 and Day 2 demos.
TypeScript
I’ve ported the code to TypeScript because I wanted to try out the language and it changes almost nothing except give me better error messages. It was basically trivial to do so.
Step one was just to create a few types:
interface Point {
x: number,
y: number,
z: number
}
type Vector = number[]
And then to update function signatures like so:
function setPixel(ctx, x, y)
function setPixel(ctx: CanvasRenderingContext2D, x: number, y: number): void
function linePlaneIntersection(p, q, n)
function linePlaneIntersection(p: Point, q: Point, n: Vector): Point
There weren’t a ton of surprises here, it all just worked straight away (and didn’t miraculously surface any bugs … though I suppose it is only a 350 line file).
One annoyance is that the built in type definitions for the KeyboardEvent
didn’t take Safari into account, so this code I just mentioned raised a TypeScript error saying that keyIdentifier
wasn’t a thing.
var keyName = e.key || e.keyIdentifier
This isn’t a great sign for TypeScript, really, as I’d like to be able to write portable code in it (I know Safari is in the wrong here but really TypeScript should take that into account in its type definitions).
I didn’t see an obvious way to fix this (say by adding to the built-in type definition) so I just did this to make it work:
var keyName = e.key || e["keyIdentifier"]
One neat thing was using TypeScript destructuring to replace the long-winded variable swapping code in the drawLine
function:
// old
if (x2 < x1) {
var xt = x1
var yt = y1
x1 = x2
y1 = y2
x2 = xt
y2 = yt
}
// new
if (x2 < x1) {
[x1, x2, y1, y2] = [x2, x1, y2, y1]
}
As to the performance of this, is it making two new arrays on each invocation?? Or is it smart enough not to? The generated javascript is this:
if (x2 < x1) {
_b = [x2, x1, y2, y1], x1 = _b[0], x2 = _b[1], y1 = _b[2], y2 = _b[3];
}
So it is creating one new array each time. Are the JavaScript VMs smart enough to optimize that object creation away, seeing that it is only used on that one line? Who knows….
Refactoring
Writing down the exact types of things made me feel a little uncomfortable that I was slinging around Arrays for Vectors, Objects (with x,y,z fields) for Points, and separate x and y variables for 2D points! So I’ve refactored to make them all objects with classes:
class Point2D {
constructor(public x: number, public y: number) {}
}
class Point {
constructor(public x: number, public y: number, public z: number) {}
}
class Vector {
constructor(public x: number, public y: number, public z: number) {}
}
So for instance the drawLine
function signature has changed like this:
function drawLine(ctx: CanvasRenderingContext2D, x1: number, y1: number, x2: number, y2: number)
function drawLine(ctx: CanvasRenderingContext2D, p: Point2D, q: Point2D): void
and the dot
function like this:
function dot(u: number[], v: number[]) {
return u[0]*v[0] + u[1]*v[1] + u[2]*v[2]
}
function dot(u: Vector, v: Vector): number {
return u.x*v.x + u.y*v.y + u.z*v.z
}
It’s worth noting that there’s absolutely nothing in TypeScript that stops you passing in a Vector or Point to an argument of type Point2D, as all that is required is that the object have both an x
and a y
field.
For instance, this is valid code even though cross
takes two Vectors not two points, because Points and Vectors have identical fields:
cross(new Point(1, 2, 3), new Point(4, 5, 6))
This is the principle of ‘duck typing’, that says that as long as an object fulfils the interface it doesn’t have to be of the same type. Usually I like this, but in the case of this project I was really hoping for a bit more type safety around when I was using a Point versus a Vector.
However, as soon as the Point and Vector classes diverge with unique properties of their own, I will get this. It’s just that at the moment they are identical so I don’t.
We probably can’t keep it. Although all these objects make the code lovely indeed… we may have to undo it all later. This is because I foresee a time when we’ll want to represent all our data as much as possible as arrays of bytes – JavaScript VMs are optimised to operate on that kind of thing very fast and we’re probably going to need the speed.
ClearRect
I changed how it was clearing the frame to black from this:
ctx.fillStyle = "black"
ctx.fillRect(0, 0, PIXEL_WIDTH*pixel_size, PIXEL_HEIGHT*pixel_size)
to this:
ctx.clearRect(0, 0, PIXEL_WIDTH*pixel_size, PIXEL_HEIGHT*pixel_size)
as I read someplace that it was faster. This also required the canvas background colour to be black, which it was already.
Performance Info
I wanted to have some info dumped on frame rate and how much time we were using to render the frames, so I added this code, which should be fairly self-explanatory:
var PERF_INFO_FRAMES = 100
// clear perf info after displaying it
function resetPerfInfo(perfInfo) {
perfInfo.lastCalcUpdateTime = Date.now()
perfInfo.frameCounter = 0
perfInfo.elapsedTimeInFunction = 0
}
// update after every frame
function updatePerfInfo(perfInfo, funcStartTime) {
perfInfo.elapsedTimeInFunction += Date.now() - funcStartTime
perfInfo.frameCounter++
// after every PERF_INFO_FRAMES frames, dump performance info
if (perfInfo.frameCounter == PERF_INFO_FRAMES) {
var timeSinceLast = Date.now() - perfInfo.lastCalcUpdateTime
var frameRate = Math.round(1000*10*PERF_INFO_FRAMES/timeSinceLast)/10
var runtimePercentage = perfInfo.elapsedTimeInFunction / (Date.now() - perfInfo.lastCalcUpdateTime)
console.log({
frameRate: frameRate,
timeBudgetUsed: Math.round(runtimePercentage*1000)/10 + "%"
})
resetPerfInfo(perfInfo)
}
}
And called it at the start and end of the drawFrame:
// set up data structure
var perfInfo = {}
resetPerfInfo(perfInfo)
function drawFrame(): void {
var funcStartTime = Date.now()
…
updatePerfInfo(perfInfo, funcStartTime)
}
This gives us nice messages in the console like this that tell us what frame rate we are getting and how much of our “time budget” (the amount of time between each frame) we are using up:
{frameRate: 60, timeBudgetUsed: "5.2%"}
{frameRate: 60, timeBudgetUsed: "5.1%"}
{frameRate: 60, timeBudgetUsed: "5.7%"}
{frameRate: 60, timeBudgetUsed: "4.9%"}
5% is quite high considering we are only animating one cube! This includes a mixed bag of computing intersections, calculating perspective, and filling in all those little squares in the canvas. Maybe later we will analyse that in more depth to see where the time is going. For now, onwards!
More Cubes!
I decided I was bored with the single blue cube demo so I added some more cubes! I had to at last encapsulate the cube vertices and edges into a Model class. I rebased the cube vertices to make 0 the centre of the cube rather than it being offset by 100 into the world.
class Model {
constructor(public vertices: Point[], public edges: Edge[]) {}
}
type Edge = number[]
var cubeModel = new Model(
[
new Point(50, 50, 50),
new Point(50, 50, -50),
new Point(50, -50, 50),
new Point(-50, 50, 50),
new Point(50, -50, -50),
new Point(-50, 50, -50),
new Point(-50, -50, 50),
new Point(-50, -50, -50),
],
[
[0, 1],
[0, 2],
[0, 3],
[1, 4],
[1, 5],
[2, 4],
[2, 6],
[3, 5],
[3, 6],
[4, 7],
[5, 7],
[6, 7],
]
)
Then each cube that exists in the world is an “Instance” of this Model, containing a reference to the model and a location of the object:
class Instance {
constructor(public model: Model, public location: Point) {}
}
Then creating an array that contains all the many many cubes there now are:
var objects: Instance[] = [
new Instance(cubeModel, new Point(0, 0, 400)),
new Instance(cubeModel, new Point(150, 0, 500)),
...
new Instance(cubeModel, new Point(-150, +300, 500)),
new Instance(cubeModel, new Point(-300, +300, 500)),
]
And rewriting drawFrame to draw the edges of the objects based on this array:
// draw edges
ctx.fillStyle = "yellow"
for (var i = 0; i < objects.length; i++) {
var object = objects[i]
for (var j = 0; j < object.model.edges.length; j++) {
var p1 = object.model.vertices[object.model.edges[j][0]]
var p2 = object.model.vertices[object.model.edges[j][1]]
var loc = object.location
var newP1 = new Point(p1.x + loc.x + transform.x, p1.y + loc.y + transform.y, p1.z + loc.z + transform.z)
var newP2 = new Point(p2.x + loc.x + transform.x, p2.y + loc.y + transform.y, p2.z + loc.z + transform.z)
var clampedLine = clampLineToView(newP1, newP2)
if (clampedLine)
drawLine3d(ctx, clampedLine[0], clampedLine[1])
}
}
And….
Nice!
Conclusion
Pretty happy with all that. TypeScript I will keep I think, as it seems to provide a level of certainty about my JavaScript that I really appreciate. There were a few times I used the “Rename symbol” option in VSCode and it worked perfectly, which is very cool.
Day 4 I have some technical screen size issues to sort out. Not glamorous but I’ve been lax about things so want to get things fixed before moving on.