Advertisement

WebGL Essentials: Part II

by

This article will build on to the framework introduced in part one of this mini-series, adding a model importer and a custom class for 3D objects. You will also be introduced to animation and controls. There's a lot to go through, so let's get started!

This article relies heavily on the first article, so, if you haven't read it yet, you should start there first.

The way WebGL manipulates items in the 3D world is by using math formulas known as transformations. So, before we start building the 3D class, I will show you some of the different kinds of transformations and how they are implemented.


Transformations

There are three basic transformations when working with 3D objects.

  • Moving
  • Scaling
  • Rotating

Each of these functions can be performed on either the X, Y, or Z axis, making a total possibility of nine basic transformations. All of these affect the 3D object's 4x4 transformation matrix in different ways. In order to perform multiple transformations on the same object without overlapping problems, we have to multiply the transformation into the object's matrix and not apply it to the object's matrix directly. Moving is the easiest to do, so let's start there.

Moving A.K.A. "Translation"

Moving a 3D object is one of the easiest transformations you can do, because there is a special place in the 4x4 matrix for it. There's no need for any math; just put the X, Y and Z coordinates in the matrix and your done. If you are looking at the 4x4 matrix, then it's the first three numbers in the bottom row. Additionally, you should know that positive Z is behind the camera. Therefore, a Z value of -100 places the object 100 units inwards on the screen. We will compensate for this in our code.

In order to perform multiple transformations, you can't simply change the object's real matrix; you must apply the transformation to a new blank matrix, known as an identity matrix, and multiply it with the main matrix.

Matrix multiplication can be a bit tricky to understand, but the basic idea is that each vertical column is multiplied by the second matrix's horizontal row. For example, the first number would be the first row multiplied by the other matrix's first column. The second number in the new matrix would be the first row multiplied by the other matrix's second column, and so on.

The following snippet is code I wrote for multiplying two matrices in JavaScript. Add this to your .js file that you made in the first part of this series:

function MH(A, B) {
    var Sum = 0;
    for (var i = 0; i < A.length; i++) {
        Sum += A[i] * B[i];
    }
    return Sum;
}

function MultiplyMatrix(A, B) {
    var A1 = [A[0], A[1], A[2], A[3]];
    var A2 = [A[4], A[5], A[6], A[7]];
    var A3 = [A[8], A[9], A[10], A[11]];
    var A4 = [A[12], A[13], A[14], A[15]];

    var B1 = [B[0], B[4], B[8], B[12]];
    var B2 = [B[1], B[5], B[9], B[13]];
    var B3 = [B[2], B[6], B[10], B[14]];
    var B4 = [B[3], B[7], B[11], B[15]];

    return [
    MH(A1, B1), MH(A1, B2), MH(A1, B3), MH(A1, B4),
    MH(A2, B1), MH(A2, B2), MH(A2, B3), MH(A2, B4),
    MH(A3, B1), MH(A3, B2), MH(A3, B3), MH(A3, B4),
    MH(A4, B1), MH(A4, B2), MH(A4, B3), MH(A4, B4)];
}

I don't think this requires any explanation, as it's just the necessary math for matrix multiplication. Let's move on to scaling.

Scaling

Scaling a model is also fairly simple - it's simple multiplication. You have to multiply the first three diagonal numbers by whatever the scale is. Once again, the order is X, Y, and Z. So, if you want to scale your object to be two times bigger in all three axes, you would multiply the first, sixth, and eleventh elements in your array by 2.

Rotating

Rotating is the trickiest transformation because there is a different equation for each of the three axis. The following image shows the rotation equations for each axis:

Don't worry if this picture doesn't make sense to you; we'll review the JavaScript implementation soon.

It's important to note that it matters what order you perform the transformations; different orders produce different results.

It's important to note that it matters what order you perform the transformations; different orders produce different results. If you first move your object and then rotate it, WebGL will swing your object around like a bat, as opposed to rotating the object in place. If you rotate first and then move your object, you will have an object in the specified location, but it will face the direction that you entered. This is because the transformations are performed around the origin point - 0,0,0 - in the 3D world. There is no right or wrong order. It all depends on the effect you are looking for.

It could require more than one of each transformations to make some advanced animations. For instance if you want a door to swing open on its hinges, you would move the door so that its hinges are on the Y axis (ie 0 on both the X and Z axis). You would then rotate on the Y axis so the door will swing on its hinges. Finally, you would move it again to the desired location in your scene.

These type of animations are a bit more custom-made for each situation, so I'm not going to make a function for it. I will, however, make a function with the most basic order which is: scaling, rotating, and then moving. This insures everything is in the specified location and facing the right way.

Now that you have a basic understanding of the math behind all of this and how animations work, let's create a JavaScript data type to hold our 3D objects.


GL Objects

Remember from the first part of this series that you need three arrays in order to draw a basic 3D object: the vertices array, the triangles array, and the textures array. That will be the base of our data type. We also need variables for the three transformations on each of the three axes. Finally, we need a variables for the texture image and to indicate whether the model has finished loading.

Here is my implementation of a 3D object in JavaScript:

function GLObject(VertexArr, TriangleArr, TextureArr, ImageSrc) {
    this.Pos = {
        X: 0,
        Y: 0,
        Z: 0
    };
    this.Scale = {
        X: 1.0,
        Y: 1.0,
        Z: 1.0
    };
    this.Rotation = {
        X: 0,
        Y: 0,
        Z: 0
    };
    this.Vertices = VertexArr;
    this.Triangles = TriangleArr;
    this.TriangleCount = TriangleArr.length;
    this.TextureMap = TextureArr;
    this.Image = new Image();
    this.Image.onload = function () {
        this.ReadyState = true;
    };
    this.Image.src = ImageSrc;
    this.Ready = false;
    //Add Transformation function Here
}

I've added two separate "ready" variables: one for when the image is ready, and one for the model. When the image is ready, I will prepare the model by converting the image into a WebGL texture and buffer the three arrays into WebGL buffers. This will speed up our application, as apposed to buffering the data in every draw cycle. Since we will convert the arrays into buffers, we need to save the number of triangles in a separate variable.

Now, let's add the function that will calculate the object's transformation matrix. This function will take all the local variables and multiply them in the order that I mentioned earlier (scale, rotation, and then translation). You can play around with this order for different effects. Replace the //Add Transformation function Here comment with the following code:

this.GetTransforms = function () {
    //Create a Blank Identity Matrix
    var TMatrix = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];

    //Scaling
    var Temp = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
    Temp[0] *= this.Scale.X;
    Temp[5] *= this.Scale.Y;
    Temp[10] *= this.Scale.Z;
    TMatrix = MultiplyMatrix(TMatrix, Temp);

    //Rotating X
    Temp = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
    var X = this.Rotation.X * (Math.PI / 180.0);
    Temp[5] = Math.cos(X);
    Temp[6] = Math.sin(X);
    Temp[9] = -1 * Math.sin(X);
    Temp[10] = Math.cos(X);
    TMatrix = MultiplyMatrix(TMatrix, Temp);


    //Rotating Y
    Temp = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
    var Y = this.Rotation.Y * (Math.PI / 180.0);
    Temp[0] = Math.cos(Y);
    Temp[2] = -1 * Math.sin(Y);
    Temp[8] = Math.sin(Y);
    Temp[10] = Math.cos(Y);
    TMatrix = MultiplyMatrix(TMatrix, Temp);

    //Rotating Z
    Temp = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
    var Z = this.Rotation.Z * (Math.PI / 180.0);
    Temp[0] = Math.cos(Z);
    Temp[1] = Math.sin(Z);
    Temp[4] = -1 * Math.sin(Z);
    Temp[5] = Math.cos(Z);
    TMatrix = MultiplyMatrix(TMatrix, Temp);


    //Moving
    Temp = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
    Temp[12] = this.Pos.X;
    Temp[13] = this.Pos.Y;
    Temp[14] = this.Pos.Z * -1;

    return MultiplyMatrix(TMatrix, Temp);
}

Because the rotation formulas overlap one another, they have to be performed one at a time. This function replaces the MakeTransform function from the last tutorial, so you can remove it from your script.


OBJ Importer

Now that we have our 3D class built, we need a way to load the data. We'll make a simple model importer that will convert .obj files into the necessary data to make one of our newly created GLObject objects. I am using the .obj model format because it stores all the data in a raw form, and it has very good documentation on how it stores the information. If your 3D modeling program doesn't support exporting to .obj, then you can always create an importer for some other data format. .obj is a standard 3D file type; so, it shouldn't be a problem. Alternatively you can also download Blender, a free cross-platform 3D modeling applications that does support exporting to .obj

In .obj files, the first two letters of every line tell us what kind of data that line contains. "v" is for a "vertex coordinates" line, "vt" is for a "texture coordinates" line, and "f" is for the mapping line. With this information, I wrote the following function:

function LoadModel(ModelName, CB) {
    var Ajax = new XMLHttpRequest();
    Ajax.onreadystatechange = function () {
        if (Ajax.readyState == 4 && Ajax.status == 200) {
            //Parse Model Data
            var Script = Ajax.responseText.split("\n");

            var Vertices = [];
            var VerticeMap = [];

            var Triangles = [];

            var Textures = [];
            var TextureMap = [];

            var Normals = [];
            var NormalMap = [];

            var Counter = 0;

This function accepts the name of a model and a callback function. The callback accepts four arrays: the vertex, triangle, texture, and normal arrays. I haven't yet covered normals, so you can just ignore them for now. I will go through them in the follow-up article, when we discuss lighting.

The importer starts by creating an XMLHttpRequest object and defining its onreadystatechange event handler. Inside the handler, we split the file into its lines and define a few variables. .obj files first define all the unique coordinates and then defines their order. That is why there are two variables for the vertices, textures, and normals. The counter variable is used to fill in the triangles array because .obj files define the triangles in order.

Next, we have to go through each line of the file and check what kind of line it is:

            for (var I in Script) {
                var Line = Script[I];
                //If Vertice Line
                if (Line.substring(0, 2) == "v ") {
                    var Row = Line.substring(2).split(" ");
                    Vertices.push({
                        X: parseFloat(Row[0]),
                        Y: parseFloat(Row[1]),
                        Z: parseFloat(Row[2])
                    });
                }
                //Texture Line
                else if (Line.substring(0, 2) == "vt") {
                    var Row = Line.substring(3).split(" ");
                    Textures.push({
                        X: parseFloat(Row[0]),
                        Y: parseFloat(Row[1])
                    });
                }
                //Normals Line
                else if (Line.substring(0, 2) == "vn") {
                    var Row = Line.substring(3).split(" ");
                    Normals.push({
                        X: parseFloat(Row[0]),
                        Y: parseFloat(Row[1]),
                        Z: parseFloat(Row[2])
                    });
                }

The first three line types are fairly simple; they contain a list of unique coordinates for the vertices, textures and normals. All we need to do is push these coordinates into their respective arrays. The last kind of line is a bit more complicated because it can contain multiple things. It could contain just vertices, or vertices and textures, or vertices, textures, and normals. As such, we have to check for each of these three cases. The following code does this:

            //Mapping Line
                else if (Line.substring(0, 2) == "f ") {
                    var Row = Line.substring(2).split(" ");
                    for (var T in Row) {
                        //Remove Blank Entries
                        if (Row[T] != "") {
                            //If this is a multi-value entry
                            if (Row[T].indexOf("/") != -1) {
                                //Split the different values
                                var TC = Row[T].split("/");
                                //Increment The Triangles Array
                                Triangles.push(Counter);
                                Counter++;

                                //Insert the Vertices 
                                var index = parseInt(TC[0]) - 1;
                                VerticeMap.push(Vertices[index].X);
                                VerticeMap.push(Vertices[index].Y);
                                VerticeMap.push(Vertices[index].Z);

                                //Insert the Textures
                                index = parseInt(TC[1]) - 1;
                                TextureMap.push(Textures[index].X);
                                TextureMap.push(Textures[index].Y);

                                //If This Entry Has Normals Data
                                if (TC.length > 2) {
                                    //Insert Normals
                                    index = parseInt(TC[2]) - 1;
                                    NormalMap.push(Normals[index].X);
                                    NormalMap.push(Normals[index].Y);
                                    NormalMap.push(Normals[index].Z);
                                }
                            }
                            //For rows with just vertices
                            else {
                                Triangles.push(Counter); //Increment The Triangles Array
                                Counter++;
                                var index = parseInt(Row[T]) - 1;
                                VerticeMap.push(Vertices[index].X);
                                VerticeMap.push(Vertices[index].Y);
                                VerticeMap.push(Vertices[index].Z);
                            }
                        }
                    }
                }

This code is more long than it is complicated. Although I covered the scenario where the .obj file only contains vertex data, our framework requires vertices and texture coordinates. If a .obj file contains only vertex data, you will have to manually add the texture coordinate data to it.

Let's now pass the arrays to the callback function and finish up the LoadModel function:

            }
            //Return The Arrays
            CB(VerticeMap, Triangles, TextureMap, NormalMap);
        }
    }
    Ajax.open("GET", ModelName + ".obj", true);
    Ajax.send();
}

Something you should watch out for is that our WebGL framework is fairly basic and only draws models that are made out of triangles. You may have to edit your 3D models accordingly. Luckily, most 3D applications have a function or plug-in to triangulate your models for you. I made a simple model of a house with my basic modeling skills, and I will include it in the source files for you to use, if you are so inclined.

Now let's modify the Draw function from the last tutorial to incorporate our new 3D object data type:

this.Draw = function (Model) {
    if (Model.Image.ReadyState == true && Model.Ready == false) {
        this.PrepareModel(Model);
    }
    if (Model.Ready) {
        this.GL.bindBuffer(this.GL.ARRAY_BUFFER, Model.Vertices);
        this.GL.vertexAttribPointer(this.VertexPosition, 3, this.GL.FLOAT, false, 0, 0);


        this.GL.bindBuffer(this.GL.ARRAY_BUFFER, Model.TextureMap);
        this.GL.vertexAttribPointer(this.VertexTexture, 2, this.GL.FLOAT, false, 0, 0);

        this.GL.bindBuffer(this.GL.ELEMENT_ARRAY_BUFFER, Model.Triangles);

        //Generate The Perspective Matrix
        var PerspectiveMatrix = MakePerspective(45, this.AspectRatio, 1, 1000.0);

        var TransformMatrix = Model.GetTransforms();
        //Set slot 0 as the active Texture
        this.GL.activeTexture(this.GL.TEXTURE0);

        //Load in the Texture To Memory
        this.GL.bindTexture(this.GL.TEXTURE_2D, Model.Image);

        //Update The Texture Sampler in the fragment shader to use slot 0
        this.GL.uniform1i(this.GL.getUniformLocation(this.ShaderProgram, "uSampler"), 0);

        //Set The Perspective and Transformation Matrices
        var pmatrix = this.GL.getUniformLocation(this.ShaderProgram, "PerspectiveMatrix");
        this.GL.uniformMatrix4fv(pmatrix, false, new Float32Array(PerspectiveMatrix));

        var tmatrix = this.GL.getUniformLocation(this.ShaderProgram, "TransformationMatrix");
        this.GL.uniformMatrix4fv(tmatrix, false, new Float32Array(TransformMatrix));

        //Draw The Triangles
        this.GL.drawElements(this.GL.TRIANGLES, Model.TriangleCount, this.GL.UNSIGNED_SHORT, 0);
    }
};

The new draw function first checks if the model has been prepared for WebGL. If the texture has loaded, it will prepare the model for drawing. We will get to the PrepareModel function in a minute. If the model is ready, it will connect its buffers to the shaders and load the perspective and transformation matrices like it did before. The only real difference is that it now takes all the data from the model object.

The PrepareModel function just converts the texture and data arrays into WebGL compatible variables. Here is the function; add it right before the draw function:

this.PrepareModel = function (Model) {
    Model.Image = this.LoadTexture(Model.Image);

    //Convert Arrays to buffers
    var Buffer = this.GL.createBuffer();

    this.GL.bindBuffer(this.GL.ARRAY_BUFFER, Buffer);
    this.GL.bufferData(this.GL.ARRAY_BUFFER, new Float32Array(Model.Vertices), this.GL.STATIC_DRAW);
    Model.Vertices = Buffer;

    Buffer = this.GL.createBuffer();

    this.GL.bindBuffer(this.GL.ELEMENT_ARRAY_BUFFER, Buffer);
    this.GL.bufferData(this.GL.ELEMENT_ARRAY_BUFFER, new Uint16Array(Model.Triangles), this.GL.STATIC_DRAW);
    Model.Triangles = Buffer;

    Buffer = this.GL.createBuffer();

    this.GL.bindBuffer(this.GL.ARRAY_BUFFER, Buffer);
    this.GL.bufferData(this.GL.ARRAY_BUFFER, new Float32Array(Model.TextureMap), this.GL.STATIC_DRAW);
    Model.TextureMap = Buffer;

    Model.Ready = true;
};

Now our framework is ready and we can move on to the HTML page.


The HTML Page

You can erase everything that is inside the script tags as we can now write the code more concisely thanks to our new GLObject data type.

This is the complete JavaScript:

var GL;
var Building;

function Ready() {
    GL = new WebGL("GLCanvas", "FragmentShader", "VertexShader");
    LoadModel("House", function (VerticeMap, Triangles, TextureMap) {
        Building = new GLObject(VerticeMap, Triangles, TextureMap, "House.png");

        Building.Pos.Z = 650;

        //My Model Was a bit too big
        Building.Scale.X = 0.5;
        Building.Scale.Y = 0.5;
        Building.Scale.Z = 0.5;

        //And Backwards
        Building.Rotation.Y = 180;

        setInterval(Update, 33);
    });
}

function Update() {
    Building.Rotation.Y += 0.2
    GL.Draw(Building);
}

We load a model and tell the page to update it at around thirty times per second. The Update function rotates the model on the Y axis, which is accomplished by updating the object's Y Rotation property. My model was a bit too big for the WebGL scene and it was backwards, so I needed to perform some adjustments in code.

Unless you are making some kind of cinematic WebGL presentation, you are probably going to want to add some controls. Let's look at how we can add some keyboard controls to our application.


Keyboard Controls

This is not really a WebGL technique as much as a native JavaScript feature, but it is handy for controlling and positioning your 3D models. All you have to do is add an event listener to the keyboard's keydown or keyup events and check which key was pressed. Each key has a special code, and a good way to find out which code corresponds to the key is to log the key codes to the console when the event fires. So go to the area where I loaded the model, and add the following code right after the setInterval line:

document.onkeydown = handleKeyDown;

This will set the function handleKeyDown to handle the keydown event. Here is the code for the handleKeyDown function:

function handleKeyDown(event) {
    //You can uncomment the next line to find out each key's code
    //alert(event.keyCode);

    if (event.keyCode == 37) {
        //Left Arrow Key
        Building.Pos.X -= 4;
    } else if (event.keyCode == 38) {
        //Up Arrow Key
        Building.Pos.Y += 4;
    } else if (event.keyCode == 39) {
        //Right Arrow Key
        Building.Pos.X += 4;
    } else if (event.keyCode == 40) {
        //Down Arrow Key
        Building.Pos.Y -= 4;
    }
}

All this function does is update the object's properties; the WebGL framework takes care of the rest.


Conclusion

We're not done! In the third and final part of this mini-series, we will review different kinds of lighting, and how to tie it all up with some 2D stuff!

Thank you for reading, and, as always, if you have any questions, feel free to leave a comment below!

Advertisement