Spaces and linear algebra.
The term space
is used throughout the documentation assuming you already know what it means.
This is also true for basic linear algebra.
In case you are not familiar, this guide aims to give you a brief introduction as well as pointers to where you may learn more.
The absolute basics
A three dimensional vector defines a point along three euclidean axes, often referred to as X
, Y
and Z
.
Or put another way, it's like a 2D point, but with an extra dimension.
You can define a 3D vector by an array with 3 numbers, e.g.:
const myVec = [1, 2, 3]; // x:1, y:2, z:3
You can also destructure that array back into individual coordinates:
const [x, y, z] = myVec;
For simplicity, we often use this form in our examples.
gl-Matrix
For anything slightly more complex we strongly recommend you use the gl-matrix library. This is a fast and lightweight linear algebra library that we use internally.
It uses number arrays to define vectors, quaternions and matrices.
const myVec = vec3.fromValues(3, 4, 0);
const length = vec3.length(myVec); // sqrt(3*3 + 4*4 + 0*0) = 5
const [x, y, z] = myVec; // destructures too.
Allocating new arrays are usually fast, but can create excessive work for the garbage collector if done too often. To alleviate this, gl-matrix requires that you specify an output array for non-scalar results:
const normalizedVec = vec3.create(); // allocate an empty 3D vector with all coordinates set to 0.
vec3.normalize(normalizedVec, myVec); // output a normalized copy of myVec into normalizedVector.
This allows you to reuse output targets multiple times, often as temporary scratch objects, or even mutate in place.
vec3.normalize(myVec, myVec);
You could spend a lot of time trying to micro-optimize these allocations. Unless your code is part of a performance critical inner loop, we generally recommend you inline allocations, however, like this:
const normalizedVec = vec3.normalize(vec3.create(), myVec);
This style lets you use a more immutable, functional style of programming that is easier to read and reason about, albeit at the cost of some performance.
Please see their tutorial and documentation for details!
On linear algebra in general there are plenty of resources, as this is a fairly mature math subject. This series on youtube may serve as an introduction/refresher for beginners.
By default, gl-matrix uses Float32Array
for matrices.
We require double precision matrices and set this to Array
of numbers at the first line in our API.
You may want to do this in your code also to avoid having a mix of matrix types.
Coordinate system
In novorender we use a right hand coordinate system, where the positive x-axis points right and the positive y-axis point up.
This is a fairly common coordinate system in the CAD world, but unlike that of most games and many other 3D engines, like three.js
.
We changed our coordinate system from positive z-axis pointing up to positive y-axis pointing up.
This currently not true for the core3D
module, which still uses the classic OpenGL coordinate system.
The View
class flips the render state for you, so unless you're using the core3D module directly, you don't have to worry about it.
Spaces
A space is a frame of reference, or coordinate system if you prefer. We usually refer to spaces in relation to other spaces. The difference can usually be expressed by a linear transformation.
Imagine you have an image and you want to rotate that image 45 degrees on screen.
The original space might be named image space
, while the displayed, rotated image might be named screen space
.
In this case, the transformation between the two spaces would be a 2D rotation.
Such transformations are reversible, i.e. the inverse matrix will reverse the transformation of the original matrix.
Or put differently, if one matrix transforms from image
to screen
space, then the inverse matrix transforms from screen
to image
space.
To help clarify this, we name our matrices by the source and target space respectively:
const angle = glMatrix.toRadian(45);
const imageToScreenTransform = mat2.fromRotation(mat2.create(), angle);
const screenToImageTransform = mat2.fromRotation(mat2.create(), -angle);
In this trivial example, it's quite obvious that the reverse of the original transform is as simple as negating the angle. More often than not, however, things are not quite that simple. For this reason, it's often necessary to compute the inverse of a matrix, which will reliably cancel out/reverse the original transform, albeit at a slightly higher cost.
const screenToImageTransform = mat2.invert(mat2.create(), imageToScreenTransform);
We can now transform 2D points/vectors back and forth between these two spaces:
const pointInImageSpace = vec2.fromValues(1,2);
const pointInScreenSpace = vec2.transformMat2(vec2.create(), pointInImageSpace, imageToScreenTransform);
const pointInImageSpaceAgain = vec2.transformMat2(vec2.create(), pointInScreenSpace, screenToImageTransform);
console.assert(glMatrix.equals(pointInImageSpace, pointInImageSpaceAgain));
Things gets a bit more complicated with 3 dimensions, but the same principles apply.
Quite often we want to move/offset/translate points, not just scale or rotate them.
To achieve this, we introduce a fourth coordinate, W
, and 4x4
transformation matrices.
Since 3D vectors only have 3 dimensions, we assign either 1
to W
component to also apply translation, or 0
to apply rotation, scaling (and shearing) only.
There's also a non-linear projection transformation when going from clip space to screen space, which enables perspective projections.
These topics are outside the scope of this guide, however.
To learn more about spaces in a typical 3D transformation pipeline, please check out Learn OpenGL
's page.
Note that their up-axis is positive Y
, not positive Z
as in our (new) engine.
World space
Most of our coordinates are defined in world space
.
You can think of this as a global coordinate, almost like a GPS position, but in 3D.
The scale is in meters.
If geolocated, these coordinates are often relative to an UTM zone.
Such zones can be thousands of kilometer across, so these coordinates can be quite large.
This is the main reason we require double precision matrices, to avoid rounding errors.
world space
is relative to planet earth, whileview space
is relative to the current camera position/rotation.
CSS space
We're also using another term, css space
, which refers to a 2D pixel coordinate relative to the view rectangle on screen, but scaled with devicePixelRatio
.
This is also referred to as CSS pixels
.
The browser will give mouse event coordinates in this space.
Unless you're planning to make your own 3D rendering module, you are unlikely to run into any other spaces that you need to understand.
Quaternions
While we use 4x4 matrices internally, they actually offer a little too much flexibility, which often means shooting yourself in the foot. Shearing, for instance, is rarely an intentional transformation. Also, ensuring matrices are normalized can be cumbersome. Finally, there is always the question if a matrix layout is row or column major.
For this and other reasons, we prefer to express most of our transformation as position, rotation and uniform scale in the public interface. This enables us to make several assumptions about the matrices that gets produced internally, which make our code simpler, faster and less likely to fail.
Quaternions are a great way to express rotation. Essentially they define a rotation axis and an angle around that axis. They are far more compact than matrices and they don't suffer the same degeneracy issues at the poles as do euler angle triplets. They are also quick to combine, invert and normalize among other things. Hence, we use them whenever it makes sense.
Debugging and reasoning about matrices can be hard. We believe the restrictions we introduce by not taking in raw 4x4 matrices will help you avoid several classic pitfalls.
If you need more flexibility, please let us know!