danlucraft /blog

3D from Scratch Day 3 - TypeScript, Refactoring, more Cubes!

December 2016 • Daniel Lucraft

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.

blog comments powered by Disqus