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!
Comments