Spaces
Suppose you're at the library looking for a particular book, and you ask the librarian where to find it. The librarian's answer might take one of the following forms:
- It's 35 feet above you and 56 feet to your right.
- That book's on the third floor, near the end of section QM.
- Five minutes ago, that book reported its location as 38° 26' 19.4784" N and 78° 52' 20.1684” W.
The book only exists in one place, but there are many ways to put an address on that one place. Each of the librarian's answers locates the book relative to a different frame of reference. In the first answer, the book is located relative to you. In the second answer, to the building and a set of shelves. In the third answer, to the globe.
So too may our 3D objects be situated in different frames of reference or spaces. As we render an object, we will pass it through a series of spaces, each suited to a particular rendering task. The vehicle for going from one space to another is a transformation matrix.
Model Space
A model is born in model space, which is the coordinate system that the 3D modeler finds most convenient to the modeling process. For example, we might model a character so its feet straddle the origin. Then its y-coordinates will represent a distance from the ground and the left half may be generated from the right by negating its x-coordinates.
Model space is the space that a model occupies before any transformations have been applied. If our model is an external asset stored in a file, our renderer reads the vertices as model space coordinates. If our model is procedurally generated, we compute the model space coordinates directly. The position data that we store in VBOs is in model space coordinates.
The position
property received in this vertex shader is in model space coordinates:
in vec3 position;
void main() {
gl_Position = vec4(position, 1.0);
}
in vec3 position; void main() { gl_Position = vec4(position, 1.0); }
Since the shader applies no transformations, the renderer is sticking with a single coordinate system throughout the whole pipeline.
World Space
Suppose we are digitally recreating the neighborhood in which we grew up. We have several tree models and want them to line the avenue, so we read them in, ship them to the graphics card, and draw them. But all the trees render on top of each other. They need to be transformed out of their native model space into a larger world space.
World space arranges the models, light sources, and human viewer under a global addressing scheme. The coordinates should be measured in units that make distance and physics computations natural, like meters. We might choose the origin to be our front porch.
The artist who created the tree models rightly put each tree in a convenient model space with the origin at its roots and the trunk climbing the y-axis. It's up to the graphics developer to situate the trees at different locations in world space. We do this by transforming the model. For example, we might rotate one of the trees 31 degrees, scale it up, and then shift it down the avenue with this transformation matrix:
const rotater = Matrix.rotateY(31);
const scaler = Matrix.scale(1.5, 1.5, 1.5);
const translater = Matrix.translate(4, 0, 7);
const worldFromModel =
translater.multiplyMatrix(scalar).multiplyMatrix(rotater);
const rotater = Matrix.rotateY(31); const scaler = Matrix.scale(1.5, 1.5, 1.5); const translater = Matrix.translate(4, 0, 7); const worldFromModel = translater.multiplyMatrix(scalar).multiplyMatrix(rotater);
The resulting transformation matrix gives each of the model space positions new addresses in world space. We name the matrix worldFromModel
to clearly announce its incoming and outgoing spaces. The operand to its right must be in model space coordinates. The value the matrix-vector multiplication produces will be in world space coordinates. Just as in scientific domains, mixing terms of incompatible units will bring you grief.
The vertex shader takes the position from model space into world space by applying this matrix:
uniform mat4 worldFromModel;
in vec3 position;
void main() {
vec4 worldPosition = worldFromModel * vec4(position, 1.0);
gl_Position = worldPosition;
}
uniform mat4 worldFromModel; in vec3 position; void main() { vec4 worldPosition = worldFromModel * vec4(position, 1.0); gl_Position = worldPosition; }
Eye Space
The color we ascribe to an object is really light that bounces off the object and happens to fall onto our retina. Accordingly, in a renderer, color is computed as a medley of the properties of the light source, the surface material, and the viewer's physical location. To simplify these lighting calculations, the spatial properties of the fragment, light sources, and the viewer are usually transformed into eye space.
Eye space is a relabeling of world space such that the viewer is at the origin and the viewer's line of sight is the negative z-axis. Transforming into eye space involves a rotation to align the world's z-axis with the line of sight and a translation to center the world around the viewer.
In a later chapter, we will build the matrix that takes positions from world space to eye space with help from a first-person camera abstraction. For the time being, we will apply a simpler matrix that pushes any objects at the world's origin away from the eye. This matrix pushes the world 10 units away from the eye, down the negative z-axis:
const eyeFromWorld = Matrix4.translate(0, 0, -10);
const eyeFromWorld = Matrix4.translate(0, 0, -10);
This vertex shader takes the model space position into world space and then eye space through two multiplications:
uniform mat4 worldFromModel;
uniform mat4 eyeFromWorld;
in vec3 position;
void main() {
vec4 eyePosition =
eyeFromWorld * worldFromModel * vec4(position, 1.0);
gl_Position = eyePosition;
}
uniform mat4 worldFromModel; uniform mat4 eyeFromWorld; in vec3 position; void main() { vec4 eyePosition = eyeFromWorld * worldFromModel * vec4(position, 1.0); gl_Position = eyePosition; }
Thanks to our naming scheme, the identifiers eyeFromWorld
and worldFromModel
glue together nicely. If we were to concatenate them into a single matrix, we'd name it eyeFromModel
. Some graphics developers call it the modelview matrix—but this name confuses the order in which the spaces are visited.
Normalized Space
Based on the spaces described above, you'd rightfully think that you can render models in a world of arbitrary size and origin. However, like most graphics systems, WebGL is very picky. It only rasterizes the chunk of space in a very small cube centered on the origin. That cube ranges from -1 to 1 on the x-axis, -1 to 1 on the y-axis, and -1 to 1 on the z-axis. If we want something to be visible, it must be in this cube.
There is a solution that satisfies both our desire to operate in a convenient space of our choosing and WebGL's policy of rendering only the unit cube: transform the chunk of the world that we want to render so that it fits inside the normalized space of the unit cube. We move from eye space to normalized space by turning each eye space coordinate into a proportion. The x-coordinates of vertices at the far left of the chunk will transform to -1, and those at the far right to 1.
A transformation that squeezes a chunk of eye space into the unit cube is called a projection matrix. There are two common kinds of projections: orthographic and perspective. In a perspective projection, farther objects appear smaller than nearer ones of the same size. This depth-scaling doesn't happen under an orthographic projection. We'll explore how to build both kinds of projection matrices momentarily.
In truth, eye space coordinates are not transformed directly into normalized space. They are really transformed into an intermediate clip space, which is like normalized space before the proportions have been divided by their denominators. In clip space, the vertices are clipped against the viewport so that no computation is wasted on offscreen fragments. We'll have more to say about clip space when we discuss perspective.
This vertex shader rounds out the sequence of matrix transformations:
uniform mat4 worldFromModel;
uniform mat4 eyeFromWorld;
uniform mat4 clipFromEye;
in vec3 position;
void main() {
vec4 clipPosition =
clipFromEye * eyeFromWorld * worldFromModel * vec4(position, 1.0);
gl_Position = clipPosition;
}
uniform mat4 worldFromModel; uniform mat4 eyeFromWorld; uniform mat4 clipFromEye; in vec3 position; void main() { vec4 clipPosition = clipFromEye * eyeFromWorld * worldFromModel * vec4(position, 1.0); gl_Position = clipPosition; }
These three matrices represent the standard transformations that graphics developers use to advance between spaces. You will encounter them in all manner of graphics libraries and game engines.
Pixel Space
There's one more space to visit. As a fragment is processed, the graphics card automatically transforms its normalized coordinates into pixel space by scaling and translating the [-1, 1] ranges of normalized space into the viewport bounds defined by the gl.viewport
call. The resulting pixel coordinates identify the pixel in the framebuffer to which the fragment color may be written.
Unlike the spaces above, you as a developer don't actively transform positions into pixel space. The graphics card does it for you.