本文基于这个系列第一部分中介绍的框架,另外还增加了一个模型导入器,和针对3D对象定制的类。 你会从中了解到动画和控制,内容很多,我们赶紧开始吧。
因为严重依赖于上一篇文章,所以,如果你还没读过,建议先读一下。
WebGL在3D世界中操纵物体的方式是使用称为变换的数学公式。所以,在我们开始构建3D类之前,我将向你展示不同类型的一些变换,以前它们是如何实现的。
变换
有三种基本变换可作用于3D对象。
- 移动
- 缩放
- 旋转
这些函数中的每一个都可作用于X轴、Y轴或Z轴,因而组合得到9种基本的变换。它们通过不同的方式来影响3D对象的4x4变换矩阵。 为了在同一个对象中执行多个变换,而不产生重叠的问题,我们要将将每个变换乘到对象的矩阵中去,而不是逐一地直接应用到对象的矩阵上。 移动变换是最简单的,我们先从移动开始。
移动又称为平移 (Translation)。
移动一个3D对象是最简单的一种变换,因为在4x4矩阵中为它保留了特殊的位置。 我们可以不用涉及任何数学;只需要把X,Y和Z坐标放到矩阵中指定位置上,就可以了。如果你观察这个4x4矩阵,你会发现它们被放在最后一行上。 此外,你需要知道的是,正Z轴指向摄像机后面。因而,Z值为-100时,会导致对象深入屏幕100个单元。在我们的代码中会对此进行补偿。
为了执行多个变换,你不能简单地修改对象的真实矩阵;你必须将变换应用于一个新的空白矩阵,称为单位矩阵,然后将其与主矩阵相乘。
矩阵乘法理解起来会有些困难,但基本思想是第一个矩阵的竖直的列乘以第二个矩阵的水平行。 比如,新矩阵第一个数为第一个矩阵第一行乘以另一矩阵的第一列。新矩阵第二个数是第一个矩阵的第一行乘以第二个矩阵的第二列,依此类推。
下面的代码片断是JavaScript中实现的矩阵乘法。将其加到你的.js
文件中,参见本系列教程第一部分。
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)];
}
我认为我们无需纠缠于如何理解这个过程,因为它们只不过是数学上矩阵乘法的必要步骤。我们接着介绍缩放吧。
缩放
缩放一个模型同样简单-因为它也是乘法。你需要将第三个对角元素乘以缩放系数。 再一次,记得顺序是X,Y和Z。所以,如果你想让你的对象在所有三个坐标轴上都变成两倍大,则你需要让第一个,第六个和第十一个元素都乘以2。
旋转
旋转是最难懂的变换,因为旋转轴在三个坐标轴上时,旋转矩阵都不一样。下图给出了每个坐标轴上的旋转方程。
如果你完全看不懂也没关系;我们马上会在JavaScript的具体实现中复习一下的。
重要的一点是,执行变换的顺序是很关键的;不同的顺序会产生不同的结果。
重要的一点是,执行变换的顺序是很关键的;不同的顺序会产生不同的结果。 如果你先移动对象然后再旋转,WebGL会像挥舞球拍一样舞动你的对象,而不只是让对象在原地旋转。 如果你先旋转再移动,则你会将对象移动到指定的位置上,只不过它会朝向你指定的方向上。 这是因为在3D世界中,变换是绕原点-0,0,0-来执行的。不存在对的或错的顺序。最终都是取决于你想要实现的效果。
要实现一些高级的动画,需要的每一种变换可能都会多个。比如,如果你想让一扇门绕绞链转动,你会先移动门,让它的绞链位于Y轴上,即在X轴和Z轴上都为零。 然后,绕Y轴旋转,这样门就可以绕绞链转动了。最后,你还需要将其再次移动,使得它可以放到场景中的指定位置上。
这些类型的动画在不同的场合下需要进行不同的定制,所以就没有必要专门写一个函数了。 不过,我会写一个函数执行最基本的顺序的变换:缩放,旋转,移动。这确保了所有物体都在指定位置,并有正确的朝向。
现在你已经对所有幕后的数学有了基本的理解,并了解了动画的工作原理,让我们创建一个JavaScript数据类型,来存储我们的3D对象。
GL对象
回忆本系列教程的第一部分,你需要三个数组来绘制一个基本的3D对象:顶点数组,三角数组和纹理数组。它们将是我们的数据类型的基础。 我们还需要用一些变量来表示在每一个轴上的三种变换。最后,我们需要用一个变量来表示纹理图像,并用来指示模型是否已经加载完毕。
下面是一个3D对象在JavaScript中的实现。
代码语言: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
}
我增加了两个独立的“ready”变量:一个用来表示图像是否准备好了,一个用于模型。当图像准备完毕,我们将通过将图像变换为WebGL纹理,以及将三个数组缓存于WebGL的缓存中,从而准备我们的模型。 这会加速我们的程序,因为不需要在每个绘制循环中都缓存一次数据。因为我们将数组存到缓存中去了,我们需要将三角形的数目存于一个独立的变量中。
现在,让我们加一个函数,来计算对象的变换矩阵。这个函数将取出所有的局部变量,并让它们以之前提到的顺序 (缩放,旋转,然后平移) 相乘。 你可以在这个变换顺序下得到一些不同的效果。将注释//Add Transformation function Here
换成如下代码:
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);
}
因为旋转公式相互重叠,它们必须一次执行一个。这个函数替换了上一个教程中的MakeTransform函数,所以你可以将它从脚本中删除。
OBJ导入器
现在,我们有了一个3D类,我们还需要一种方式来导入数据。我们将编写一个简单的模型导入器,它会将.obj
文件变换为必要的数据,然后得到一个我们新创建的GLObject
的对象。 使用.obj
模型格式的原因在于,它用一种原始的形式来存储所有的数据,并且它有很好的文档介绍它的信息存储方式。 如果你的3D建模程序不支持导出.obj
文件,则你总是可以编写一个基它数据格式的导入器。 .obj
是一种标准的3D文件类型;所以,应该不会有什么问题。或者,你也可以安装Blender,这是一个跨平台的3D建模程序,它是支持导出.obj
的。
在.obj
文件中,每一行的头两个字母告诉我们该行中包含了什么类型的数据。 "v
"表示一个"顶点坐标"行,"vt
"表示一个"纹理坐标"行,而"f
"是一个映射行。基于这些信息,我编写了下面的函数:
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;
此函数接受两个参数:模型名称和回调函数。回调函数接受四个数组作为参数:顶点,三角形,纹理和法向量数组。 我之前还没介绍过法向量,所以你可以现在暂时忽略。我会在接下来的文章中讨论光照时进行介绍。
这个导入器首先创建一个XMLHttpRequest
对象,并定义它的onreadystatechange
事件处理器。在此处理器内部,我们将文件分割成行,然后定义了一些变量。 .obj
文件首先定义了所有的唯一坐标,并定义它们的顺序。这也是为什么为顶点、纹理和法向量定义了两个变量的原因。 计数器counter变量用于填充三角形数组,因为.obj
文件是按照顺序定义这些三角形的。
接下来,我们必须遍历文件的每一行,并检查它们各自是哪一种类型:
代码语言:javascript复制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])
});
前三行非常简单;它们包含了唯一性坐标的一个列表,用于顶点、纹理和法向量。 我们需要做的是将这些坐标存入相应的数组中。最后一种行的类型稍微复杂一些,因为它包含了多个东西。 它可以包含顶点,或顶点和纹理,或顶点、纹理和法向量。这样,我们不得不检查是这三种情况中的哪一种。下面的代码实现了这个功能:
代码语言:javascript复制//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);
}
}
}
}
这个代码虽然长,但并不算复杂。虽然我讨论了.obj文件中只包含有顶点数据的情况,但我们的框架还需要顶点坐标和纹理坐标。 如果一个.obj文件只包含顶点数据,你将必须手动地添加纹理坐标数据。
现在,让我们将这些数据传递给回调函数,并完成我们的LoadModel
函数。
}
//Return The Arrays
CB(VerticeMap, Triangles, TextureMap, NormalMap);
}
}
Ajax.open("GET", ModelName ".obj", true);
Ajax.send();
}
你需要小心的是,我们的WebGL框架是非常基本的,只能画用三角形构造出来的模型。所以,你需要相应地编辑你的3D模型。 幸运的是,大部分3D应用都支持或有插件支持模型的三角化。我通过基本的建模技术构造了一个简单的房子的模型,包含在源码中,供你使用。
现在,让我们修改上篇文章中的Draw
函数,使之能够处理我们新的3D模型的数据类型。
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);
}
};
新的绘制函数首先检查模型是否已经为WebGL准备好。如果纹理已经加载,它会开始准备绘制模型。我们呆会儿会介绍这个PrepareModel
函数。 如果模型准备好了,它会连接到着色器中的缓存,并和之前一样,加载透视矩阵和变换矩阵。唯一实在的差别在于,它的所有数据都来自于模型对象。
PrepareModel
函数只不过是将纹理和数据数组转变为与WebGL兼容的变量。下面就是这个函数;将它加到绘制函数之前。
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;
};
现在,我们的框架已经完成,我们可以开始修改HTML页面。
HTML页面
你可以清除script
标签中的所有代码,由于新的GLObject
的功劳,我们可以把代码写得更紧凑一些。
下面是完整的JavaScript代码:
代码语言: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);
}
我们加载一个模型,告诉页面每秒钟更新30次。Update
函数让模型绕Y轴旋转,这是通过更新这个对象的Y轴Rotation
实现的。 我的模型对于WebGL来说还是大了一些,这不太好,所以我需要在代码中稍作调整。
除非你想要那种影院般的WebGL展示,你很可能希望添加一些控制功能。让我们看看如何在应用中添加鼠标控制功能。
键盘控制
这只不过是原生的JavaScript功能,并非WebGL的技术,但它对于控制和放置3D模型是很有帮助的。你需要做的全部事情只是为键盘的keydown
或keyup
事件添加一个事件监听器,并检查到底是哪个键被按下了。 每个键都一个特殊的代码,找出这种对应关系的一种较好的办法是在事件触发时在终端中记录下按键的代码。所以,在加载模型的代码处,在setInterval
行之后添加如下的代码:
document.onkeydown = handleKeyDown;
这会设置函数handleKeyDown
,来处理keydown
事件。下面是handleKeyDown
函数的代码:
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;
}
}
这个函数的功能是更新对象的属性;而我们WebGL框架会处理剩下的所有事情。
更多精彩内容,请微信关注”前端达人”公众号!
新年大礼包
关注“前端达人”公众号,回复“新年大礼包”获取英文电子书:
更多精彩内容,请微信关注”前端达人”公众号!