Vector3

How to 3D

Chapter 1: Points and Lines

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:

$$\begin{aligned} \text{origin} &= \begin{bmatrix}0 & 0 & 0\end{bmatrix} \\ \text{target} &= \begin{bmatrix}-5 & 8 & 2\end{bmatrix} \end{aligned}$$

The lines that pass through the origin are the axes. People disagree on how to name these axes. This book follows these conventions:

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.

← Vertex ArrayHomogeneous Coordinates →