Chapters 2–4: Canvas and Matrices
In the previous post I went a little further than the exercise at the end of the chapter asked for and created a web component that exercised my tuple implementation and included animation of the projectile. As it turns out, this wound up being very similar to the exercise at the end of chapter 2, which is about implementing a canvas. So I decided to skip that exercise and continue onto the next two chapters which walk you through implementing various matrix math operations.
Implementing the Canvas Class
Implementing a canvas class when targeting a web runtime is perhaps unnecessary since
<canvas>
provides a solid 2D canvas JavaScript API. However, the book has you
implement color functions for tuples containing floats so a canvas that stores colors with
floats instead of integer values seems like it might be a better bet going forward. So I
decided to implement my own Canvas
class backed by a Float32Array
.
I went with 32-bit floats over the JavaScript-native 64-bit floats since I am still planning on porting this to AssemblyScript to take advantage of the v128 SIMD operations in WebAssembly. The “128” in “v128” implies you can either have an SIMD instruction operate either on 4 32-bit floats or 2 64-bit floats. Four-times-faster is better than two-times-faster. And based on a bit of research the extra precision is usually not needed or at least easy to avoid needing.
Using a TypedArray
also opens the doors to backing a canvas with a
SharedArrayBuffer
or something similar. I can imagine this being
useful by having the ray tracer running in many WebWorkers, all updating a shared canvas.
There’s a bit of a snag with SharedArrayBuffer
however…
A Side Quest to Move Away From GitHub Pages
In response to the Spectre vulnerability, browser vendors updated the SharedArrayBuffer
constructor to throw so that it could not be abused until they had a fix. The fix they
ultimately adopted requires sending two HTTP headers with your HTML document. Well, you
can’t set HTTP headers on GitHub Pages, where I was previously hosting this site.
I always planned on moving this site to my personal website, limulus.net. But my setup for limulus.net is very out-of-date. I have a GitHub repository for it but deployment is no longer automated. I just manually upload any changes to S3. None of the other infrastructure for it like the CloudFront distribution has been turned into CloudFormation templates so it’s all just sitting in AWS resources without any version control. I wanted to avoid adding to that mess for now by publishing to GitHub Pages.
Even though I know SharedArrayBuffer
may not be how I ultimately choose to implement
things I also didn’t want to be in the situation where I am forced into switching away from
GitHub Pages in the middle of the project instead of early on. In the (ok, unlikely) event
that anyone was subscribed to the RSS feed setting up redirects on GitHub Pages for that
might be tricky. Better to just get it out of the way as soon as possible.
In a bid to get things done though I resisted the urge to write CloudFormation templates for
everything, so unfortunately I have added to my AWS technical debt. However I did spend the
time set things up in the new ways AWS recommends: I’m using GitHub’s OIDC
provider to get temporary AWS credentials for the GitHub Action that publishes
this site and I avoided setting up the S3 bucket to use public website mode. I learned that
to get CloudFront to serve index.html
files for directories served from a private S3
origin you need to write a CloudFront Function to rewrite the request. So unfortunately that
means I now have a tiny bit of code for hosting this site that is not version controlled.
But at least now I know how to set these things up in a more secure way.
Implementing the Matrix Class
There’s only a few things particularly interesting about the implementation of my Matrix
class.
A Surprise Subclass
It likely should not have come as a surprise that the Tuple
implementation would need to
be treated as a matrix when doing matrix math operations. In fact, tuples need to be treated
as matrixes of four rows, which is not my intuition about how to conceptualize an array of
four items. It was seeming like I was going to need special handling in my Matrix
class to
account for whenever it was passed a Tuple
and that felt messy. The solution I landed on
was to create a TwoDimensionalArray
class that would act as the base class for both the
Tuple
and Matrix
classes. This way, the Tuple class can construct itself with 1 column
and 4 rows and the Matrix
class doesn’t have to treat Tuple
s as special cases.
This kind of refactor is definitely where having a robust test suite (in this case provided by the book) shines. I had confidence in a relatively substantial refactor without any added effort.
Chaining Matrix Transformations
While I won’t pretend to understand the reasons why (maybe I knew back when I took linear algebra?) if you want a transformation matrix to have multiple transformations you have to multiply them in reverse order. In other words, if you want a matrix that you can use to do a translation, then a rotation, then a scaling up, you need to first multiply the scaling matrix by the rotation matrix, and then the result by the translation matrix. The books suggests that you implement a “fluent” API of chainable methods that takes care of this for you. For example:
const twoOClock = Matrix.transformation()
.translate(0, 1, 0)
.rotateZ(-(2 / 12) * 2 * Math.PI)
.scale(clockRadius, -clockRadius, 0)
What I’ve seen frequently in JS APIs is that they often require something like a final
.done()
method to perform the final calculations and produce the end result of the chain
of operations. However, there is a way around this if you can structure your class to have
the first attempt to read the values of the returned object do the finalization.
Here’s how that works with my Matrix
class. The following is a selection of methods that
demonstrate it. The .translate()
, .rotateZ()
, and scale()
methods in the example above
all call .#pushOperation()
to push their operation onto the #operationStack
array and
return this
.
export class Matrix extends TwoDimensionalArray {
static transformation() {
const chainable = Matrix.identity(4)
chainable.#operationStack = []
return chainable
}
#operationStack?: Matrix[]
protected override get values() {
if (this.#operationStack) {
const operationStack = this.#operationStack
this.#operationStack = undefined
const result = operationStack.reduceRight(
(result, operation) => result.mul(operation),
this
)
super.values = result.values
}
return super.values
}
#pushOperation(operation: Matrix): this {
if (!this.#operationStack) {
throw new Error('Attempted to push operation to non-chainable matrix')
}
this.#operationStack.push(operation)
return this
}
}
The Demo: <pixel-clock>
The end-of-chapter exercise for chapter 5 is to use your matrix and canvas implementation to color in a pixel for every hour of a 12 hour analog clock. I did two things I really didn’t have to for this exercise: add animated “hands” and perform the rendering in a Web Worker.
Now that I have some hands-on experience with Web Workers I expect to be able to offload the work of the ray tracer off the main thread, and possibly even parallelize the work into multiple workers.
Up Next: Finally Casting Some Rays!
Now that these fundamentals are out of the way and I’ve got this site hosted where I want it, there should be less of a delay until the next post. With any luck the next post will also not be quite as long!