Vector3
We locate things in physical space with addresses, like streets and house numbers and latitudes and longitudes. Our virtual spaces need addresses too. The Cartesian coordinate system places an imaginary grid of perpendicular lines over space. Some special point on the grid is chosen as the origin, the anchor from which all other locations are measured. A location's coordinates are the distances one must travel along the grid's lines to reach the location.
Cartesian coordinates are often written in vector form:
The lines that pass through the origin are the axes. People disagree on how to name these axes. This book follows these conventions:
- The x-axis spans from left to right.
- The y-axis spans from bottom to top.
- The z-axis spans from into the screen to in front of the screen. That is, the positive z-axis points toward the viewer, not away from the viewer.
Face your right palm toward you and form a fist. Stick out your thumb; it's the positive x-axis. Stick out your index finger; it's the positive y-axis. Stick out your middle finger; it's the positive z-axis. The vector \(\begin{bmatrix}-5&8&2\end{bmatrix}\) is 5 units opposite your thumb, 8 units along your index finger, and 2 units along your middle finger.
You likely already know these terms because you have been dealing with Cartesian coordinate systems for many years. But now it's time to start programming with them.
Vector3 Class
Let's write an abstraction of a three-dimensional vector. This time we won't give you the code directly. Instead we'll build up the abstraction bit by bit through a series of exercises.
Occasionally the clients of Vector3
will need to access the individual x-, y-, or z-coordinates. We don't have standalone variables for these, so clients can't access them directly. If we've recently been writing Java, we might be tempted to define a method like getX()
that returns the array element. Calling a method is syntactically noisy. JavaScript provides a middle way: accessor properties. This Angle
class has an accessor property named degrees
:
class Angle {
radians: double;
constructor(radians: double) {
this.radians = radians;
}
get degrees() {
return this.radians * 180 / Math.PI;
}
set degrees(value: number) {
this.radians = value * Math.PI / 180;
}
}
class Angle { radians: double; constructor(radians: double) { this.radians = radians; } get degrees() { return this.radians * 180 / Math.PI; } set degrees(value: number) { this.radians = value * Math.PI / 180; } }
Accessor properties are fake instance variables. We reference them as if they were public instance variables, but we are actually calling getter and setter methods. The client writes code like this:
const angle = new Angle(Math.PI);
console.log(angle.degrees);
angle.degrees = 45;
const angle = new Angle(Math.PI); console.log(angle.degrees); angle.degrees = 45;
There is no instance variable named degrees
. The second line has a call to the getter, and third line has a call to the setter.
Another good name for these fake instance variables would be dynamic properties, since they get reevaluated on every reference. This name more clearly communicates their computational cost and behavior. Accessor properties look cheap from the client's perspective, but they might be expensive if the code iterates, allocates memory, or performs I/O.
Create file lib/vector.ts
and enter in the class you have built.
Immutability
Though we just defined three setters for the Vector3
class, we should keep our classes immutable as much as possible. That is, instead of mutating the state of a vector, give back a new instance with the modified state. For example, this invert
function is mutable:
invert() {
this.x = -this.x;
this.y = -this.y;
this.z = -this.z;
}
invert() { this.x = -this.x; this.y = -this.y; this.z = -this.z; }
This inverse
function is immutable:
inverse() {
return new Vector3(-this.x, -this.y, -this.z);
}
inverse() { return new Vector3(-this.x, -this.y, -this.z); }
Prefer the immutable inverse
over the mutable invert
because immutability reduces the danger of shared data being modified in unpredictable ways.
Testing with a Driver
Our Vector3
class is a foundational part of the library of graphics code we will develop throughout this course. To keep it bug-free, we'll need to test it. We could test it from within a renderer running in the browser, but sometimes a simple script that we can run from the terminal is easier to wrap our heads around. Let's figure out how to write one of those.
First, add this toString
method to Vector3
to make it easier to print:
export class Vector3 {
// ...
toString() {
return `[${this.x}, ${this.y}, ${this.z}]`;
}
}
export class Vector3 { // ... toString() { return `[${this.x}, ${this.y}, ${this.z}]`; } }
Create file test/driver.ts
and add this code:
import {Vector3} from './vector.js';
const vector = new Vector3(1, 2, 3);
console.log(vector.toString());
import {Vector3} from './vector.js'; const vector = new Vector3(1, 2, 3); console.log(vector.toString());
Notice that this script imports the JavaScript version of the file, not the TypeScript version. This script is going to be run by Node rather than through the browser, and Node doesn't currently support TypeScript directly. Run the tester in the terminal with this command:
npm run driver
npm run driver
The driver
script is defined in package.json
to run the TypeScript compiler and then execute the resulting build/driver.js
.
Use this driver script as a sandbox to test the code we write outside the context of a renderer. In particular, add the following immutable methods to the Vector3
class and test their results:
add(that: Vector3)
, which returns a new Vector3
that is the sum of this
and that
. Adding two vectors means adding their corresponding coordinates.scalarMultiply(scalar: number)
, which returns a new Vector3
that is a scaled version of this
. Each coordinate is scaled, or multiplied, by scalar
.normalize()
, which returns a new Vector3
in which each coordinate of this
is divided by the vector's magnitude.Vector3 and VertexAttributes
Earlier in the hello-cornflower
renderer we made a vertex buffer for a list of positions using a flat array of numbers, like this:
const positions = [
0.0, 0.0, 0, // vertex 0 is at the origin
0.5, 0.5, 0, // vertex 1 is northeast
];
const positions = [ 0.0, 0.0, 0, // vertex 0 is at the origin 0.5, 0.5, 0, // vertex 1 is northeast ];
Soon we will want to represent each position as a Vector3
so that we can perform mathematical operations on the positions. What happens if we define the positions
array like this?
const positions = [
new Vector3(0, 0, 0),
new Vector3(0.5, 0.5, 0.5),
];
const positions = [ new Vector3(0, 0, 0), new Vector3(0.5, 0.5, 0.5), ];
Currently this generates an error because the VertexAttributes
class expects a flat array of numbers. To use an array of Vector3
, we must first flatten the vectors using the flatMap
function:
const positions = [
new Vector3(0, 0, 0),
new Vector3(0.5, 0.5, 0),
].flatMap(position => position.xyz);
const positions = [ new Vector3(0, 0, 0), new Vector3(0.5, 0.5, 0), ].flatMap(position => position.xyz);
This code first transforms or maps each vector into its raw array form, producing an array of arrays. Then it collects all the inner arrays' elements into one flat array.
That's enough coding for this first renderer. Commit and push your code to your remote Git repository.