Categories
Angular Web Development

Intro to WebGL using Angular – How to Build in 3D (Part 3)

Prerequisites for beginning a 3D build

  • You’ve completed part 1 (setting up a scene)
  • You’ve completed part 2 (setting up and compiling shaders).

Thinking in 3D with Angular & WebGL

So we’ve got a square, cool. Do you know what’s even better? A CUBE! Let’s build one!

We’ve already covered a lot of the fundamentals of building, binding and rendering simple geometry in WebGL. We’ll be further extending upon what we’ve currently built to now handle 3D.

The general process is simple, add additional vertex positions and colours to our existing buffers to visualize a 3D scene instead of 2D.

This means we need to add in a Z-axis to our vertex and colour points.

How to define positions for a 3D cube

In order to do this, we need to think about how to define these points.

First, how many faces are there on a cube? There’s a total of six faces. Therefore, in order to build a 3D cube, we need to define vector positions for all six cube faces. WebGL can then render the cube as expected.

E.g.

// illustrates points in 2D space
const positions2D = new Float32Array([
   // front face
   1.0,  1.0, 
  -1.0,  1.0, 
   1.0, -1.0, 
  -1.0, -1.0
]);

// illustrates points in 3D space
const positions3D = new Float32Array([
   // Front face
    -1.0, -1.0,  1.0,
     1.0, -1.0,  1.0,
    -1.0,  1.0,  1.0,

     1.0,  1.0,  1.0,
    -1.0,  1.0,  1.0,
     1.0, -1.0,  1.0,

    // Back face
    -1.0, -1.0, -1.0,
    -1.0,  1.0, -1.0,
     1.0,  1.0, -1.0,

     1.0,  1.0, -1.0,
     1.0, -1.0, -1.0,
    -1.0, -1.0, -1.0,

    // Top face
    -1.0,  1.0, -1.0,
    -1.0,  1.0,  1.0,
     1.0,  1.0,  1.0,

     1.0,  1.0,  1.0,
     1.0,  1.0, -1.0,
    -1.0,  1.0, -1.0,

    // Bottom face
    -1.0, -1.0, -1.0,
     1.0, -1.0, -1.0,
     1.0, -1.0,  1.0,

     1.0, -1.0,  1.0,
    -1.0, -1.0,  1.0,
    -1.0, -1.0, -1.0,

    // Right face
    1.0, -1.0, -1.0,
    1.0,  1.0, -1.0,
    1.0,  1.0,  1.0,

    1.0,   1.0,  1.0,
    1.0,  -1.0,  1.0,
    1.0,  -1.0, -1.0,

    // Left face
    -1.0, -1.0, -1.0,
    -1.0, -1.0,  1.0,
    -1.0,  1.0,  1.0,

    -1.0,  1.0,  1.0,
    -1.0,  1.0, -1.0,
    -1.0, -1.0, -1.0,
]);

In the array definition above, you can see that we’ve defined each point for each face of the cube we want rendered.

For each face we’ve defined four positions, each position is represented with an x, y, z coordinate. A total of 36 points have now been successfully defined. Defining these points allowed WebGL to build our cube in 3D space.

How to define colours for a 3D cube

Lets now update the way we define colours for our cube by implementing the code below in initialiseBuffers():

// Set up the colors for the vertices
const faceColors = [
  [1.0,  1.0,  1.0,  1.0],    // Front face: white
  [1.0,  0.0,  0.0,  1.0],    // Back face: red
  [0.0,  1.0,  0.0,  1.0],    // Top face: green
  [0.0,  0.0,  1.0,  1.0],    // Bottom face: blue
  [1.0,  1.0,  0.0,  1.0],    // Right face: yellow
  [1.0,  0.0,  1.0,  1.0],    // Left face: purple
];

// Convert the array of colors into a table for all the vertices.
let colors = [];
for (let j = 0; j < faceColors.length; ++j) {
  const c = faceColors[j];

  // Repeat each color six times for the three vertices of each triangle
  // since we're rendering two triangles for each cube face
  colors = colors.concat(c, c, c, c, c, c);
}

In the code above, we define RGBA colours for each cube face.

The for loop we define iterates through the array of face colours and builds a table of array data that applies colour values for all of the cube points.

The usage of colors = colors.concat(c,c,c,c,c,c) might look confusing at first, but essentially all it’s doing is creating an array with four entries based on the faceColor row we’ve retrieved.

It helps to quickly build up a buffer of colours for each of the two triangle points that make up one face of the cube (E.g. Triangle 1 = TL, TR, BR and Triangle 2 = TL, BL, BR) and adds the result to the colors array and then continues onto the next faceColor item.

E.g.

TL _ _ _ _ _ TR
 | \        |
 |   \  T2  |    T1 = TL, BL, BR 
 |     \    |    T2 = TL, TR, BR
 |  T1   \  |    6 points that we need to color in
 |_ _ _ _ _\|
BL           BR

By the end of this process, we have a table of array data which represents the intended colours for every point we desire.

If we were to manually type this out, the result would look like this:

const colors = new Float32Array([
    1.0,  1.0,  1.0,  1.0,    // Front face: white
    1.0,  1.0,  1.0,  1.0,    // Front face: white
    1.0,  1.0,  1.0,  1.0,    // Front face: white
    1.0,  1.0,  1.0,  1.0,    // Front face: white
    1.0,  1.0,  1.0,  1.0,    // Front face: white
    1.0,  1.0,  1.0,  1.0,    // Front face: white
    1.0,  0.0,  0.0,  1.0,    // Back face: red
    1.0,  0.0,  0.0,  1.0,    // Back face: red
    1.0,  0.0,  0.0,  1.0,    // Back face: red
    1.0,  0.0,  0.0,  1.0,    // Back face: red
    1.0,  0.0,  0.0,  1.0,    // Back face: red
    1.0,  0.0,  0.0,  1.0,    // Back face: red
    0.0,  1.0,  0.0,  1.0,    // Top face: green
    0.0,  1.0,  0.0,  1.0,    // Top face: green
    0.0,  1.0,  0.0,  1.0,    // Top face: green
    0.0,  1.0,  0.0,  1.0,    // Top face: green
    0.0,  1.0,  0.0,  1.0,    // Top face: green
    0.0,  1.0,  0.0,  1.0,    // Top face: green
    0.0,  0.0,  1.0,  1.0,    // Bottom face: blue
    0.0,  0.0,  1.0,  1.0,    // Bottom face: blue
    0.0,  0.0,  1.0,  1.0,    // Bottom face: blue
    0.0,  0.0,  1.0,  1.0,    // Bottom face: blue
    0.0,  0.0,  1.0,  1.0,    // Bottom face: blue
    0.0,  0.0,  1.0,  1.0,    // Bottom face: blue
    1.0,  1.0,  0.0,  1.0,    // Right face: yellow
    1.0,  1.0,  0.0,  1.0,    // Right face: yellow
    1.0,  1.0,  0.0,  1.0,    // Right face: yellow
    1.0,  1.0,  0.0,  1.0,    // Right face: yellow
    1.0,  1.0,  0.0,  1.0,    // Right face: yellow
    1.0,  1.0,  0.0,  1.0,    // Right face: yellow
    1.0,  0.0,  1.0,  1.0,    // Left face: purple
    1.0,  0.0,  1.0,  1.0,    // Left face: purple
    1.0,  0.0,  1.0,  1.0,    // Left face: purple
    1.0,  0.0,  1.0,  1.0    // Left face: purple
    1.0,  0.0,  1.0,  1.0    // Left face: purple
    1.0,  0.0,  1.0,  1.0    // Left face: purple
]);

This would be pretty redundant to write out manually, so we use a for loop to help keep things simple and achieve our goal.

Update bindVertexPosition()

Let’s go back to our bindVertexPosition() function and update bufferSize from 2 to 3 in order to account for the Z-axis we’re now including as part of our position.

This small update lets WebGL know to now pull 3 items per vertex attribute position for rendering.

Cleaning up web-gl.service.ts

Create a new method in web-gl.service.ts and call it formatScene().

Add in the following:

/**
  * Formats the scene for rendering (by resizing the WebGL canvas and setting the defaults for WebGL drawing).
  */
public formatScene() {
    this.updateWebGLCanvas();
    this.resizeWebGLCanvas();
    this.updateViewport();
}

Create a getter for the modelViewMatrix property we have in the service:

/**
  * Gets the {@link modelViewMatrix}.
  *
  * @returns modelViewMatrix
  */
getModelViewMatrix(): mat4 {
    return this.modelViewMatrix;
}

We’ll need to reference this matrix when we want to render our cube and apply some animation / rotational and translation effects to it.

Go to the prepareScene() method and update the entire implementation with the following:

/**
 * Prepare's the WebGL context to render content.
 */
prepareScene() {
    // tell WebGL how to pull out the positions from the position
    // buffer into the vertexPosition attribute
    this.bindVertexPosition(this.programInfo, this.buffers);

    // tell WebGL how to pull out the colors from the color buffer
    // into the vertexColor attribute.
    this.bindVertexColor(this.programInfo, this.buffers);

    // tell WebGL to use our program when drawing
    this.gl.useProgram(this.programInfo.program);

    // set the shader uniforms
    this.gl.uniformMatrix4fv(
        this.programInfo.uniformLocations.projectionMatrix,
        false,
        this.projectionMatrix
    );
    this.gl.uniformMatrix4fv(
        this.programInfo.uniformLocations.modelViewMatrix,
        false,
        this.modelViewMatrix
    );
}

Essentially we’ve just removed a few lines that we previously had where we were resizing and updating the WebGL Canvas within this method, and then applying the matrix.mat4.translate(...) operation to move the modelViewMatrix “backwards” so we could view the rendered square.

We’re moving our old code over to the scene.component.ts so it can be responsible for performing matrix translate, rotate, scale operations instead of defining it here in the service.

Updating drawScene() in scene.component.ts

Let’s update drawScene() in scene.component.ts with a bit of code to now render out our updated buffer data.

Add an import to gl-matrix at the top of the file.

import * as matrix from 'gl-matrix';

Add these two variables to the SceneComponent class underneath the private gl: WebGLRenderingContext definition: e.g.

...
private _60fpsInterval = 16.666666666666666667;
private gl: WebGLRenderingContext
private cubeRotation = 0;
private deltaTime = 0;
constructor(private webglService: WebGLService) {}
...

Great, lets update ngAfterViewInit(): void with the following:

ngAfterViewInit(): void {
    if (!this.canvas) {
      alert('canvas not supplied! cannot bind WebGL context!');
      return;
    }
    this.gl = this.webglService.initialiseWebGLContext(
      this.canvas.nativeElement
    );
    // Set up to draw the scene periodically via deltaTime.
    const milliseconds = 0.001;
    this.deltaTime = this._60fpsInterval * milliseconds;
    const drawSceneInterval = interval(this._60fpsInterval);
    drawSceneInterval.subscribe(() => {
      this.drawScene();
      this.deltaTime = this.deltaTime + (this._60fpsInterval * milliseconds);
    });
}

We’ve added a little bit of code here which calculates an output of deltaTime based on 60fps multipled by 0.001 milliseconds.

All this incrementing deltaTime by the calculation above each time we render a frame. We set the result of deltaTime to the cubeRotation variable to specify the amount of rotation we want to apply to the cube in radians every time we render a frame.

Update drawScene() with the following:

/**
 * Draws the scene
 */
private drawScene() {
    // prepare the scene and update the viewport
    this.webglService.formatScene();

    // draw the scene
    const offset = 0;
    // 2 triangles, 3 vertices, 6 faces
    const vertexCount = 2 * 3 * 6;

    // translate and rotate the model-view matrix to display the cube
    const mvm = this.webglService.getModelViewMatrix();
    matrix.mat4.translate(
        mvm,                    // destination matrix
        mvm,                    // matrix to translate
        [0.0, 0.0, -6.0]        // amount to translate
        );
    matrix.mat4.rotate(
        mvm,                    // destination matrix
        mvm,                    // matrix to rotate
        this.cubeRotation,      // amount to rotate in radians
        [1, 1, 1]               // rotate around X, Y, Z axis
    );

    this.webglService.prepareScene();

    this.gl.drawArrays(
        this.gl.TRIANGLES,
        offset,
        vertexCount
    );

    // rotate the cube
    this.cubeRotation = this.deltaTime;
}

Observe, we’re now calling webglService.formatScene() above to easily set and update the viewport for rendering. We’ve also updated the vertexCount variable that we had hardcoded to 4 in the last tutorial to now reflect what is being rendered on screen. vertexCount = 2 * 3 * 6. 

‘2’ represents the number of triangles we’re rendering per cube face, ‘3' represents the number of vertices for each triangle, and ‘6’ represents the number of cube faces we’re rendering. This number calculates a total of 36 vertices. This matches the number of vertex positions we defined in const positions = [] in initialiseBuffers().

In the next bit of code, I’m retrieving the modelViewMatrix and using it to perform a translate on the Z-axis to push our rendered cube backwards. We can then view it and then apply a rotate to it based on cubeRotation which is set to the updated deltaTime after the render loop is completed.

Finally, we call webglService.prepareScene() to bind all vertex and color buffers and then make a call to gl.drawArrays(this.gl.TRIANGLES, offset, vertexCount) to render the cube on screen!

If you did everything correctly, you should now see the following when you run npm start on the solution:

The cube 

You’ve now successfully built a 3-dimensional cube in WebGL using Angular!

In part 4 we’ll look at indexed vertices and adding textures to our cube in WebGL using Angular!

As usual, the source code for this tutorial is available here: https://gitlab.com/MikeHewett/intro-webgl-part-3.git

References