Lab 6: 3D Worlds#

In Lab 5: Transformations we looked at the transformations can be applied to the vertex coordinates \((x, y, z, 1)\) but all of our examples were using transformations in 2D. In this lab we will take the step into the third spatial dimension and look at 3D worlds.

3D models#

To demonstrate building a simple 3D world we are going to need a 3D object. One of the simplest 3D objects is a unit cube which is a cube centred at (0,0,0) and has side lengths of 2 parallel to the coordinate axes (Fig. 44) so the coordinates of the 8 corners of the cube are combinations of \(-1\) and \(1\). Since we use triangles as our basic cube consists of 12 triangles (6 square sides each constructed using out of 2 triangles).

../_images/06_unit_cube.svg

Fig. 44 A unit cube centred at \((0,0,0)\) with side lengths of 2.#

Open the 3D_worlds.js file and you will see that the vertices and indices have been defined for a unit cube.

// Define cube vertices
  const vertices = new Float32Array([
    // x  y  z      r  g  b     u  v                    + ------ +
    // front                                           /|       /|
    -1, -1,  1,     0, 0, 0,    0, 0,  //    y        / |      / |
     1, -1,  1,     0, 0, 0,    1, 0,  //    |       + ------ +  |
     1,  1,  1,     0, 0, 0,    1, 1,  //    +-- x   |  + ----|- +
    -1, -1,  1,     0, 0, 0,    0, 0,  //   /        | /      | /   
     1,  1,  1,     0, 0, 0,    1, 1,  //  z         |/       |/
    -1,  1,  1,     0, 0, 0,    0, 1,  //            + ------ +   
    // right                        
     1, -1,  1,     0, 0, 0,    0, 0,
     1, -1, -1,     0, 0, 0,    1, 0, 
     1,  1, -1,     0, 0, 0,    1, 1,
     1, -1,  1,     0, 0, 0,    0, 0,
     1,  1, -1,     0, 0, 0,    1, 1,
     1,  1,  1,     0, 0, 0,    0, 1,
    // etc.
  ]);

  // Define cube indices
  const indices = new Uint16Array([
     0,  1,  2,  3,  4,  5,  // front
     6,  7,  8,  9, 10, 11,  // right
    12, 13, 14, 15, 16, 17,  // back
    18, 19, 20, 21, 22, 23,  // left
    24, 25, 26, 27, 28, 29,  // bottom
    30, 31, 32, 33, 34, 35   // top
  ]);

If you compile and run this program you will see that a crate texture fills the canvas. This is because the coordinates of the cube vertices are \(-1\) and \(1\).

../_images/06_unit_cube.png

Fig. 45 The unit cube.#


Coordinate systems#

WebGL uses a coordinate system with the \(x\)-axis pointing horizontally to the right, the \(y\)-axis pointing vertically upwards and the \(z\)-axis pointing horizontally towards the viewer. To simplify things when it comes to displaying the 3D world, the axes are limited to a range from \(-1\) to \(1\), so any object outside this range will not be shown on the display. These are known as Normalised Device Coordinates (NDC).

../_images/06_NDC.svg

Fig. 46 Normalised Device Coordinates (NDC)#

The steps used in the creation of a 3D world and eventually displaying it on screen requires that we transform through several intermediate coordinate spaces.

  • Model space – each individual 3D object that will appear in the 3D world is defined in its own space usually with the volume centre of the object at \((0,0,0)\) to make the transformations easier

../_images/06_model_space.svg

Fig. 47 The model space.#

  • World space – the 3D world is constructed by transforming the individual 3D objects using translation, rotation and scaling transformations.

../_images/06_world_space.svg

Fig. 48 The world space.#

  • View space – the world space is transformed so that it is viewed from \((0,0,0)\) looking down the \(z\)-axis.

../_images/06_view_space.svg

Fig. 49 The view space.#

  • Screen space –the 3D view space is projected onto a 2D projection plane.

../_images/06_screen_space.svg

Fig. 50 The screen space.#


Model, view and projection matrices#

We saw in Lab 5: Transformations that we apply a transformation by multiplying the object coordinates by a transformation matrix. Since we are transforming between difference coordinate spaces we have 3 main transformation matrices:

  • Model matrix - transforms the model space coordinates for the objects to the world space

  • View matrix - transforms the world space coordinates to the view space coordinates

  • Projection matrix - transforms the view space coordinates to the screen space NDC coordinates

The Model matrix#

In Lab 5: Transformations we saw that we can combine transformations such as translation, scaling and rotation by multiplying the individual transformation matrices together. Let’s compute a model matrix for our cube where it is scaled down by a factor of 0.5 in each coordinate direction, rotated about the \(y\)-axis and translated backwards down the \(z\)-axis so that its centre is at \((0, 0, -2)\).

Task

Edit the render() function in the 3D_worlds.js file so that the transformation matrices look like the following.

// Calculate the model matrix
const translate = new Mat4().translate(0, 0, -2);
const scale     = new Mat4().scale(0.5, 0.5, 1);
const angle     = 1/3 * time * 2 * Math.PI * 0.001;
const rotate    = new Mat4().rotate(0, 0, 1, angle);
const model     = translate.multiply(rotate).multiply(scale);

Here we have calculated the individual transformation matrices for translation, scaling and rotation and multiply them together to create the model matrix. The rotation angle has been calculated using the time of the current frame so that the cube will perform one full rotation every 3 seconds.


The View matrix#

To view the world space we create a virtual camera and place it in the world space. We need to translate the whole of the world space so that the camera is at \((0,0,0)\) and then rotate the world space so that the camera is pointing down the \(z\)-axis (Fig. 49). To do this we require three vectors (Fig. 51):

  • \(\vec{eye}\): the coordinates of the camera position,

  • \(\vec{target}\): the coordinates of the target point that the camera is pointing,

  • \(\vec{worldUp}\): a vector pointing straight up in the world space which allows us to orientate the camera, this is usually \((0, 1, 0)\)

../_images/06_view_space_alignment.svg

Fig. 51 The vectors used in the transformation to the view space.#

The eye and target vectors are either determined by the user through keyboard, mouse or controller inputs or through some predetermined routine. To determine the view space transformation we first translate the camera position by negative of the eye vector so that it is at \((0, 0, 0)\) using the following translation matrix

\[\begin{split} \begin{align*} Translate = \begin{pmatrix} 1 & 0 & 0 & -\vec{eye}_x \\ 0 & 1 & 0 & -\vec{eye}_y \\ 0 & 0 & 1 & -\vec{eye}_z \\ 0 & 0 & 0 & 1 \end{pmatrix} \end{align*}, \end{split}\]

The next step is to align the world space so that the direction vector is pointing down the \(z\)-axis. To do this we calculate three camera vectors (Fig. 51):

  • \(\vec{front}\) vector which extends outwards in front of the camera towards the target,

  • \(\vec{right}\) vector which extends outwards to the right of the camera,

  • \(\vec{up}\) vector which extends straight up from the camera.

These three vectors are all unit vectors (have a length of 1) and are at right-angles to each other. The front vector is calculated using

(15)#\[ \vec{front} = \operatorname{normalize}(\vec{target} - \vec{eye}).\]

The right vector points is at right-angles to both the front and world up vectors. We can use the cross product between the two vectors to calculate this (note that the order of the vectors is important).

(16)#\[ \vec{right} = \operatorname{normalize}( \vec{front} \times \vec{world Up}).\]

The up vector points is at right-angles to the front and right vectors, so this can be calculated using another cross product

(17)#\[ \vec{up} = \operatorname{normalize}(\vec{right} \times \vec{front}).\]

Once these vectors have been calculated the transformation matrix to rotate the camera so that it is looking down the \(z\)-axis is

\[\begin{split} Rotate = \begin{pmatrix} \vec{right}_x & \vec{right}_y & \vec{right}_z & 0 \\ \vec{up}_x & \vec{up}_y & \vec{up}_z & 0 \\ -\vec{front}_x & -\vec{front}_y & -\vec{front}_z & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix}.\end{split}\]

The translation matrix and rotation matrix are multiplied together to form the view matrix which transforms the world space coordinates to the view space.

\[\begin{split} \begin{align*} View &= Rotate \times Translate \\ &= \begin{pmatrix} \vec{right}_x & \vec{right}_y & \vec{right}_z & 0 \\ \vec{up}_x & \vec{up}_y & \vec{up}_z & 0 \\ -\vec{front}_x & -\vec{front}_y & -\vec{front}_z & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix} \begin{pmatrix} 1 & 0 & 0 & -\vec{eye}_x \\ 0 & 1 & 0 & -\vec{eye}_y \\ 0 & 0 & 1 & -\vec{eye}_z \\ 0 & 0 & 0 & 1 \end{pmatrix} \\ &= \begin{pmatrix} \vec{right}_x & \vec{right}_y & \vec{right}_z & -\vec{eye} \cdot \vec{right} \\ \vec{up}_x & \vec{up}_y & \vec{up}_z & -\vec{eye} \cdot \vec{up} \\ -\vec{front}_x & -\vec{front}_y & -\vec{front}_z & \vec{eye} \cdot \vec{front} \\ 0 & 0 & 0 & 1 \end{pmatrix} \end{align*} \end{split}\]

So the transposed view matrix is

(18)#\[\begin{split} View = \begin{pmatrix} \vec{right}_x & \vec{up}_x & -\vec{front}_x & 0 \\ \vec{right}_y & \vec{up}_y & -\vec{front}_y & 0 \\ \vec{right}_z & \vec{up}_z & -\vec{front}_z & 0 \\ -\vec{eye} \cdot \vec{right} & -\vec{eye} \cdot \vec{up} & \vec{eye} \cdot \vec{front} & 1 \end{pmatrix} \end{split}\]

Task

Create a file called camera.js inside which enter the following code.

class Camera {

  constructor() {
    // Camera vectors
    this.eye     = new Vec3(0, 0, 0);
    this.worldUp = new Vec3(0, 1, 0);
    this.front   = new Vec3(0, 0, -1);
    this.right   = new Vec3(1, 0, 0);
    this.up      = new Vec3(0, 1, 0);
  }

  // Update camera vectors
  updateVectors() {
    this.right = this.front.cross(this.worldUp).normalize();
    this.up    = this.right.cross(this.front).normalize();
  }

  // LookAt
  lookAt() {
    return new Mat4().set(
      this.right.x, this.up.x, -this.front.x, 0,
      this.right.y, this.up.y, -this.front.y, 0,
      this.right.z, this.up.z, -this.front.z, 0,
      -this.eye.dot(this.right),
      -this.eye.dot(this.up),
       this.eye.dot(this.front),
      1
    );
  }
}

Here we have create a Camera class that will be used to compute anything that is related to the camera. The constructor function defines 5 camera class vectors such that the camera is positioned at \(\vec{eye} = (0,0,0)\), looking in the direction of \(\vec{front} = (0, 0, -1)\) (i.e., down the \(z\)-axis). We also defined the methods .updateVectors() which calculates the \(\vec{right}\) and \(\vec{up}\) camera vectors using equations (16) and (17), and .lookAt() which calculates returns the view matrix using equation (18).

Task

Enter the following code before the render() function in the 3D_worlds.js file.

// Camera object
const camera = new Camera();

And add the following to the render() function after we clear the canvas.

  // Update camera vectors
  const target = new Vec3(0, 0, -2);
  camera.eye   = new Vec3(1, 1, 1);
  camera.front = target.subtract(camera.eye).normalize();
  camera.updateVectors();

  // Calculate view and projection matrices
  const view       = camera.lookAt();

Here we create a camera object, set the \(\vec{eye}\) and \(\vec{front}\) camera vectors so that the camera is positioned at \((1,1,1)\) and looking towards the centre of the translated cube at \((0, 0, -2)\) (using equation (15)) and then calculate the view matrix using the .lookAt() method.


The Projection matrix#

The next step is to project the view space onto the screen space. The simplest type of projection is orthographic projection where the coordinates in the view space are transformed to the screen space by simple translation and scaling transformations.

The region of the view space that will form the screen space is defined by a cuboid bounded by a left, right, bottom, top, near and far clipping planes. Any objects outside the cuboid are clipped (Fig. 52).

../_images/06_orthographic_projection.svg

Fig. 52 Orthographic projection.#

The transpose of the orthographic projection matrix is calculated using

(19)#\[\begin{split} \begin{align*} Orthographic = \begin{pmatrix} \dfrac{2}{right - left} & 0 & 0 & 0 \\ 0 & \dfrac{2}{top - bottom} & 0 & 0 \\ 0 & 0 & -\dfrac{2}{far - near} & 0 \\ -\dfrac{right + left}{right - left} & -\dfrac{top + bottom}{top - bottom} & -\dfrac{far + near}{far - near} & 1 \end{pmatrix} \end{align*}, \end{split}\]

where \(left\), \(right\), \(bottom\), \(top\), \(near\) and \(far\) are the coordinates of the edges of the visible space. You don’t really need to know how this matrix is derived but if you are interested click on the dropdown link below.

Derivation of the orthographic projection matrix

To derive the orthographic projection we first need to translate the coordinates so that the centre of the cuboid that represents the clipping volume to \((0,0,0)\). The centre coordinates are calculated using the average of the edge coordinates, e.g., for the \(x\) coordinate this would be \(\dfrac{right + left}{2}\), so the translation matrix is

\[\begin{split} \begin{align*} Translate = \begin{pmatrix} 1 & 0 & 0 & -\dfrac{right + left}{2} \\ 0 & 1 & 0 & -\dfrac{top + bottom}{2} \\ 0 & 0 & 1 & -\dfrac{far - near}{2} \\ 0 & 0 & 0 & 1 \end{pmatrix} \end{align*} \end{split}\]

The second step is to scale the clipping volume so that the coordinates are between \(-1\) and \(1\). This is done by dividing the distance between the edges of the screen space by the distance between the clipping planes, e.g., for the \(x\) coordinate this would be \(\dfrac{1 - (-1)}{right - left}=\dfrac{2}{right - left}\), so the scaling matrix is

\[\begin{split} \begin{align*} Scale = \begin{pmatrix} \dfrac{2}{right - left} & 0 & 0 & 0 \\ 0 & \dfrac{2}{top - bottom} & 0 & 0 \\ 0 & 0 & -\dfrac{2}{far - near} & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix}. \end{align*} \end{split}\]

Combining the translation and scaling matrices gives the orthographic projection matrix

\[\begin{split} \begin{align*} Orthographic &= Translate \times Scale \\ &= \begin{pmatrix} \dfrac{2}{right - left} & 0 & 0 & -\dfrac{right + left}{right - left} \\ 0 & \dfrac{2}{top - bottom} & 0 & -\dfrac{top + bottom}{top - bottom} \\ 0 & 0 & -\dfrac{2}{far - near} & -\dfrac{far + near}{far - near} \\ 0 & 0 & 0 & 1 \end{pmatrix} \end{align*} \end{split}\]

This matrix is transposed when coding in JavaScript.

Task

Add the following method definition to the Camera class.

// Orthographic projection
orthographic(left, right, bottom, top, near, far) {
  const rl = 1 / (right - left);
  const tb = 1 / (top - bottom);
  const fn = 1 / (far - near);

  return new Mat4().set(
    2 * rl, 0, 0, 0,
    0, 2 * tb, 0, 0,
    0, 0, -2 * fn, 0,
    -(right + left) * rl,
    -(top + bottom) * tb,
    -(far + near) * fn,
    1
  );
  }

And add the following to the render() function file after we have calculated the view matrix.

const projection = camera.orthographic(-2, 2, -2, 2, 0, 100);

Here we have defined the method .orthographic() that returns the orthographic projection matrix from equation (19) and used it to calculate the projection matrix.


The MVP matrix#

Now that we have the model, view and projection matrices we need to apply them to the vertices. Rather than sending three separate matrices to the shaders, we multiply them using the CPU and send a single \(4 \times 4\) matrix to the GPU known as the MVP matrix.

\[ MVP = Projection \times View \times Model. \]

Task

Edit the code that sends the model matrix to the shader so that it looks like the following.

// Calculate MVP matrix and send it to the shaders
const mvp = projection.multiply(view).multiply(model);
gl.uniformMatrix4fv(gl.getUniformLocation(shaderProgram, "uMvp"), false, mvp.m);

Here we multiply the model, view and projection matrices to give the MVP matrix which is sent to the shader as the uMvp uniform. We now need to update the vertex shader so that is uses the MVP matrix. The only change we need to make is changing the uModel uniform to uMvp.

#version 300 es
precision mediump float;

in vec3 aPosition;
in vec3 aColour;
in vec2 aTexCoord;

out vec3 vColour;
out vec2 vTexCoord;

uniform mat4 uMvp;

void main() {
  gl_Position = uMvp * vec4(aPosition, 1.0);

  // Output vertex colour
  vColour = aColour;

  // Output texture co-ordinates
  vTexCoord = aTexCoord;
}

Task

Edit the vertex shader code at the top of the 3D_worlds.js file so that it looks like the vertex shader shown above.

Refresh your web browser and you should the rotating cube below.


The depth test#

Our rendering of the cube doesn’t look quite right. What is happening here is that some parts of the sides of the cube that are further away from where we are viewing it (e.g., the bottom side) have been rendered after the sides that are closer to us (Fig. 53).

../_images/06_depth_test.svg

Fig. 53 Rendering the far triangle after the near triangle.#

To overcome this issue WebGL uses a depth test when computing the fragment shader. When WebGL creates a frame buffer it also creates another buffer called a z buffer (or depth buffer) where the \(z\) coordinate of each pixel in the frame buffer is stored and initialises all the values to \(-1\) (the furthest possible \(z\) coordinate in the screen space). When the fragment shader is called it checks whether the fragment has a \(z\) coordinate more than that already stored in the depth buffer and if so it updates the colour of the fragment and stores its \(z\) coordinate in the depth-buffer as the current nearest fragment (if the fragment has a \(z\) coordinate less than what is already in the depth buffer the fragment shader does nothing). This means once the fragment shader has been called for all fragments of all objects, the pixels contain colours of the objects closest to the camera.

Task

Add the following where the WebGL canvas is configured in the main() function

gl.enable(gl.DEPTH_TEST);

And change command to clear the canvas in the render() function to the following.

// Clear canvas
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

Here we first enabled WebGL’s depth test and then we clear the depth buffer at the start of the rendering of each frame. Refresh your web browser and you should get a much better result.


Perspective projection#

The problem with using orthographic projection is that is does not give us any clues to how far an object is from the viewer. We would expect that objects further away from the camera would appear smaller whereas objects closer to the camera would appear larger. To demonstrate this we are going to add another cube to our scene which is placed futher away from the camera.

Task

Add the following to the global variables section at the top of the 3D_worlds.js file.

const cubePositions = [
  0, 0, -2,
  0, 0, -6
];
const numberOfCubes = 2;

Now put the commands used to calculate the model matrix, the MVP matrix and to draw the cube inside a for loop.

// Draw cubes
for (let i = 0; i < numCubes; i++) {

  // Calculate the model matrix
  const translate = new Mat4().translate(0, 0, -2);
  const scale     = new Mat4().scale(0.5, 0.5, 0.5);
  const angle     = 1/3 * time * 2 * Math.PI * 0.001;
  const rotate    = new Mat4().rotate(0, 1, 0, angle);
  const model     = translate.multiply(rotate).multiply(scale);

  // Calculate MVP matrix and send it to the shaders
  const mvp = projection.multiply(view).multiply(model);
  gl.uniformMatrix4fv(gl.getUniformLocation(shaderProgram, "uMvp"), false, mvp.m);

  // Draw triangles
  gl.bindVertexArray(VAO);
  gl.drawElements(gl.TRIANGLES, 36, gl.UNSIGNED_SHORT, 0);
}

Change the translation matrix so that it uses the new cube centre coordinates.

const tx = cubePositions[i * 3 + 0];
const ty = cubePositions[i * 3 + 1];
const tz = cubePositions[i * 3 + 2];
const translateMatrix = translate(tx, ty, tz);

Finally change the rotation matrix so that it uses an angle of zero.

const rotateMatrix = rotate([0, 1, 0], 0);

Here we first define an array of 3-element vectors which stores the centre coordinates of two cubes. We then loop through the two cubes, calculate the MVP matrix for each one and draw the cube. We have also stopped the cubes from rotating by setting the rotation angle to zero. Refresh your web browser and you should see something like the following.

../_images/06_orthographic_cubes.png

Fig. 54 Orthographic projection.#

The cube in the front is centred at \((0, 0, -2)\) and the cube behind is centred at \((0, 0, -5)\). Using orthographic projection both cubes appear the same size despite one being further away from the camera which is located at \((1,1,1)\).

Perspective projection is used to render objects where the further an object is from the camera, the small it appears in the canvas. It use the same near and far clipping planes as orthographic projection but the clipping planes on the sides are not parallel, rather they angle in such that the four planes meet at \((0,0,0)\) (Fig. 55). The clipping volume bounded by the size clipping planes is called the viewing frustum.

../_images/06_perspective_projection.svg

Fig. 55 Perspective projection.#

The shape of the viewing frustum is determined by four factors:

  • \(near\) – the distance from \((0,0,0)\) to the near clipping plane

  • \(far\) – the distance from \((0,0,0)\) to the far clipping plane

  • \(fov\) – the field of view angle between the bottom and top clipping planes (used to determine how much of the view space is visible)

  • \(aspect\) – the width-to-height aspect ratio of the window

Given these four factors we can calculate the transpose of the perspective projection matrix using

(20)#\[\begin{split} \begin{align*} Perspective = \begin{pmatrix} \dfrac{near}{right} & 0 & 0 & 0 \\ 0 & \dfrac{near}{top} & 0 & 0 \\ 0 & 0 & -\dfrac{near + far}{far - near} & -1 \\ 0 & 0 & - \dfrac{2\times far \times near}{far - near} & 0 \end{pmatrix}, \end{align*} \end{split}\]

where \(top = near \times \tan\left(\dfrac{fov}{2}\right)\) and \(right = aspect \times top\). You don’t really need to know how this is derived but if you are interested click on the dropdown below.

Derivation of the perspective projection matrix

The mapping of a point in the view space with coordinates \((x, y, z)\) onto the near clipping plane to the point \((x', y', -near)\) is shown in Fig. 56.

../_images/06_perspective_projection_mapping.svg

Fig. 56 Mapping of the point at \((x,y,z)\) onto the near plane using perspective.#

The ratio of \(x\) to \(-z\) distance is the same as the ratio of \(x'\) to \(near\) distance (and similar for \(y'\)) so

\[\begin{split} \begin{align*} \dfrac{x}{-z} &= \dfrac{x'}{near} &\implies x' &= -near \frac{x}{z}, \\ \dfrac{y}{-z} &= \dfrac{y'}{near} &\implies y' &= -near \frac{y}{z}, \end{align*} \end{split}\]

So we are mapping \((x, y)\) to \(\left( -near \dfrac{x}{z}, -near \dfrac{y}{z} \right)\). As well as the perspective mapping we also need to ensure that the mapped coordinates \((x', y', z')\) are between \(-1\) and \(1\). Consider the mapping of the \(x\) coordinate

\[\begin{split} \begin{align*} left &\leq x' \leq right \\ -right &\leq x' \leq right && \textsf{(since $left = -right$)} \\ -1 &\leq \frac{x'}{right} \leq 1 && \textsf{(divide by $right$)} \end{align*} \end{split}\]

Since \(x' = -near\dfrac{x}{z}\) then

\[ \begin{align*} -1 &\leq -\frac{near}{right}\frac{x}{z}\leq 1 \end{align*} \]

and doing similar for \(y\) we get

\[ \begin{align*} -1 &\leq -\frac{near}{top}\frac{y}{z}\leq 1 \end{align*} \]

If we use homogeneous coordinates then this mapping can be represented by the matrix equation

\[\begin{split} \begin{align*} \begin{pmatrix} \dfrac{near}{right} & 0 & 0 & 0 \\ 0 & \dfrac{near}{top} & 0 & 0 \\ 0 & 0 & A & B \\ 0 & 0 & -1 & 0 \end{pmatrix} \begin{pmatrix} x \\ y \\ z \\ 1 \end{pmatrix} = \begin{pmatrix} \dfrac{near}{right}x \\ \dfrac{near}{top}y \\ Az + B \\ -z \end{pmatrix} \end{align*} \end{split}\]

where \(A\) and \(B\) are placeholder variables for now. Since we divide homogeneous coordinates by the fourth element then the projected coordinates are

\[\begin{split} \begin{align*} \begin{pmatrix} x' \\ y' \\ z' \\ 1 \end{pmatrix} = \begin{pmatrix} -\dfrac{near}{right}\dfrac{x}{z} \\ -\dfrac{near}{top}\dfrac{y}{z} \\ \dfrac{Az + B}{-z} \\ 1 \end{pmatrix}. \end{align*} \end{split}\]

So the mapping for \(x'\) and \(y'\) is correct. We need \(z'\) to be between \(-1\) and \(1\) so \(A\) and \(B\) must satisfy

\[\begin{split} \begin{align*} \textsf{near plane:} &&\frac{Az + B}{-z} &= -1, & \implies Az + B &= z, \\ \textsf{far plane:} && \frac{Az + B}{-z} &= 1, & \implies Az + B &= -z. \end{align*} \end{split}\]

At the near clipping plane \(z = -near\) and at the far clipping plane \(z = -far\) so

\[\begin{split} \begin{align*} -A \times near + B &= -near, \\ -A \times far + B &= far. \end{align*} \end{split}\]

Subtracting the first equation from the second gives

\[\begin{split} \begin{align*} -A (far - near) &= far + near \\ \therefore A &= -\frac{far + near}{far - near}. \end{align*} \end{split}\]

Substituting \(A\) in the second equation gives

\[\begin{split} \begin{align*} \left(\frac{far + near}{far - near}\right) near + B &= -near \\ B &= -near \left( 1 + \frac{far + near}{far - near}\right) \\ &= -near \left( \frac{far - near + far + near}{far - near}\right) \\ &= - \frac{2 \times far \times near}{far - near}. \end{align*} \end{split}\]

So the perspective projection matrix is

\[\begin{split} \begin{align*} Perspective = \begin{pmatrix} \dfrac{near}{right} & 0 & 0 & 0 \\ 0 & \dfrac{near}{top} & 0 & 0 \\ 0 & 0 & -\dfrac{far + near}{far - near} & - \dfrac{2\times far \times near}{far - near} \\ 0 & 0 & -1 & 0 \end{pmatrix}. \end{align*} \end{split}\]

We now need to calculate the values of \(r\) and \(t\). The \(t\) coordinate is the opposite side of a right angled triangle with angle \(\dfrac{fov}{2}\) and adjacent side \(n\) so it is easily calculated using trigonometry

\[\begin{split} \begin{align*} \tan \left( \frac{fov}{2} \right) &= \frac{top}{near} \\ t &= near \tan \left( \frac{fov}{2} \right). \end{align*} \end{split}\]

Since \(aspect\) with the width of the window divided by the height and \(l = -r\) and \(b = -t\) then

\[\begin{split} \begin{align*} aspect &= \frac{right - left}{top - bottom} = \frac{2 \times right}{2 \times top} \\ \therefore right &= aspect \times top. \end{align*} \end{split}\]

Task

Add the following method definition to the Camera class.

// Perspective projection
perspective() {
  const top = this.near * Math.tan(0.5 * this.fov * Math.PI / 180);
  const right = this.aspect * top;
  const fn = 1 / (this.far - this.near);

  return new Mat4().set(
    this.near / right, 0, 0, 0,
    0, this.near / top, 0, 0,
    0, 0, -(this.near + this.far) * fn, -1,
    0, 0, -2 * this.far * this.near * fn, 0
  );
}

Comment out the command used to calculate the projection matrix in the render() function and add the following.

const projection = camera.perspective();

Refresh your web browser and you should see something similar to the following.

../_images/06_perspective_cubes.png

Fig. 57 Perspective projection.#


Changing the fov angle#

The field of view angle determines how much of the view space we can see in the screen space where the larger the angle the more we can see. When we increase the field of view angle it appears to the user that our view is zooming out whereas a decrease has the effect of zooming in (this is used a lot in first-person shooter games to model the effect of a pair of binoculars or a sniper scope).

Experiment with the affect of changing the field of view angle.

../_images/06_fov_15.png

Fig. 58 \(fov = 15^\circ\)#

../_images/06_fov_120.png

Fig. 59 \(fov = 120^\circ\)#


Exercises#

  1. Move the camera so that it circles the first cube centred at \((0, 0, -2)\) with radius of 5 and a height of 2. The camera should perform one full rotation every 5 seconds. Hint: \(x = c_x + r\cos(\theta)\) and \(z = c_z + r\sin(\theta)\) gives the \(x\) and \(y\) coordinates on a circle centred at \((c_x, c_y, c_z)\) with radius \(r\).

  1. Rotate the cubes that have an odd index number about the vector \((1,1,1)\) so that they complete one full rotation every 2 seconds. Hint: x % y returns the remainder when x is divided by y, e.g., 3 % 2 will return 1.

  1. Add a feature to your program that allows the user to increase or decrease the field of view angle using the up and down arrow keys. Your code should limit the field of view angle so it is never less than \(10^\circ\) or greater than \(90^\circ\). Hint: The keyboardInput() function at the bottom of the Lab06_3D_worlds.cpp file checks if the escape key has been pressed and quits the application if it has.

  1. Create a \(10 \times 10\) grid of cubes in the world space.

_images/06_Ex5.png
  1. Add functions called lookAt() and perspective() to your Maths class that calculate the view and perspective projection matrices. Replace the use of the equivalent glm functions with your own.


Video walkthrough#

The video below walks you through these lab materials.